diff --git a/backend/migrations/add_archer_tickets_table.js b/backend/migrations/add_archer_tickets_table.js new file mode 100644 index 0000000..9583833 --- /dev/null +++ b/backend/migrations/add_archer_tickets_table.js @@ -0,0 +1,50 @@ +// Migration: Add archer_tickets table +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_tracker.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting Archer tickets migration...'); + +db.serialize(() => { + // Create archer_tickets table + db.run(` + CREATE TABLE IF NOT EXISTS archer_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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 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('✓ archer_tickets table created'); + }); + + // Create indexes + db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor)', (err) => { + if (err) console.error('Error creating CVE index:', err); + else console.log('✓ CVE index created'); + }); + + db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status)', (err) => { + if (err) console.error('Error creating status index:', err); + else console.log('✓ Status index created'); + }); + + db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number)', (err) => { + if (err) console.error('Error creating EXC number index:', err); + else console.log('✓ EXC number index created'); + }); + + console.log('✓ Indexes created'); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/routes/archerTickets.js b/backend/routes/archerTickets.js new file mode 100644 index 0000000..bf10e3d --- /dev/null +++ b/backend/routes/archerTickets.js @@ -0,0 +1,214 @@ +// routes/archerTickets.js +const express = require('express'); +const { requireAuth, requireRole } = require('../middleware/auth'); +const { isValidCveId, isValidVendor } = require('../helpers/validators'); +const { logAudit } = require('../helpers/auditHelpers'); + +function createArcherTicketsRouter(db) { + const router = express.Router(); + + // Get all Archer tickets (with optional filters) + router.get('/', requireAuth(db), (req, res) => { + const { cve_id, vendor, status } = req.query; + + let query = 'SELECT * FROM archer_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 Archer tickets:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json(rows); + }); + }); + + // Create Archer ticket + router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { + const { exc_number, archer_url, status, cve_id, vendor } = req.body; + + // Validation + if (!exc_number || typeof exc_number !== 'string' || exc_number.trim().length === 0) { + return res.status(400).json({ error: 'EXC number is required.' }); + } + if (!/^EXC-\d+$/.test(exc_number.trim())) { + return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' }); + } + 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 (archer_url && (typeof archer_url !== 'string' || archer_url.length > 500)) { + return res.status(400).json({ error: 'Archer URL must be under 500 characters.' }); + } + if (status && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) { + return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' }); + } + + const validatedStatus = status || 'Draft'; + + db.run( + `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor) + VALUES (?, ?, ?, ?, ?)`, + [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor], + function(err) { + if (err) { + console.error('Error creating Archer ticket:', err); + if (err.message.includes('UNIQUE constraint failed')) { + return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); + } + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit(db, { + userId: req.user.id, + action: 'CREATE_ARCHER_TICKET', + targetType: 'archer_ticket', + targetId: this.lastID, + details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, + ipAddress: req.ip + }); + + res.status(201).json({ + id: this.lastID, + message: 'Archer ticket created successfully' + }); + } + ); + }); + + // Update Archer ticket + router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { + const { id } = req.params; + const { exc_number, archer_url, status } = req.body; + + // Validation + if (exc_number !== undefined) { + if (typeof exc_number !== 'string' || exc_number.trim().length === 0) { + return res.status(400).json({ error: 'EXC number cannot be empty.' }); + } + if (!/^EXC-\d+$/.test(exc_number.trim())) { + return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' }); + } + } + if (archer_url !== undefined && archer_url !== null && (typeof archer_url !== 'string' || archer_url.length > 500)) { + return res.status(400).json({ error: 'Archer URL must be under 500 characters.' }); + } + if (status !== undefined && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) { + return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' }); + } + + // Get existing ticket + db.get('SELECT * FROM archer_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: 'Archer ticket not found.' }); + } + + const updates = []; + const params = []; + + if (exc_number !== undefined) { + updates.push('exc_number = ?'); + params.push(exc_number.trim()); + } + if (archer_url !== undefined) { + updates.push('archer_url = ?'); + params.push(archer_url || null); + } + if (status !== undefined) { + updates.push('status = ?'); + params.push(status); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No fields to update.' }); + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + db.run( + `UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`, + params, + function(err) { + if (err) { + console.error(err); + if (err.message.includes('UNIQUE constraint failed')) { + return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); + } + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit(db, { + userId: req.user.id, + action: 'UPDATE_ARCHER_TICKET', + targetType: 'archer_ticket', + targetId: id, + details: { before: existing, changes: req.body }, + ipAddress: req.ip + }); + + res.json({ message: 'Archer ticket updated successfully', changes: this.changes }); + } + ); + }); + }); + + // Delete Archer ticket + router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => { + const { id } = req.params; + + db.get('SELECT * FROM archer_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: 'Archer ticket not found.' }); + } + + db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) { + if (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } + + logAudit(db, { + userId: req.user.id, + action: 'DELETE_ARCHER_TICKET', + targetType: 'archer_ticket', + targetId: id, + details: { deleted: ticket }, + ipAddress: req.ip + }); + + res.json({ message: 'Archer ticket deleted successfully' }); + }); + }); + }); + + return router; +} + +module.exports = createArcherTicketsRouter; diff --git a/backend/server.js b/backend/server.js index 2fbb04f..b95d2eb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -20,6 +20,7 @@ const logAudit = require('./helpers/auditLog'); const createNvdLookupRouter = require('./routes/nvdLookup'); const createWeeklyReportsRouter = require('./routes/weeklyReports'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); +const createArcherTicketsRouter = require('./routes/archerTickets'); const app = express(); const PORT = process.env.PORT || 3001; @@ -179,6 +180,9 @@ app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload)); // Knowledge base routes (editor/admin for upload/delete, all authenticated for view) app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload)); +// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view) +app.use('/api/archer-tickets', createArcherTicketsRouter(db)); + // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) diff --git a/frontend/src/App.js b/frontend/src/App.js index fda3cd3..22a0b66 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown } from 'lucide-react'; +import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react'; import { useAuth } from './contexts/AuthContext'; import LoginForm from './components/LoginForm'; import UserMenu from './components/UserMenu'; @@ -210,6 +210,16 @@ export default function App() { // For adding ticket from within a CVE card const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor } + // Archer tickets state + const [archerTickets, setArcherTickets] = useState([]); + const [showAddArcherTicket, setShowAddArcherTicket] = useState(false); + const [showEditArcherTicket, setShowEditArcherTicket] = useState(false); + const [editingArcherTicket, setEditingArcherTicket] = useState(null); + const [archerTicketForm, setArcherTicketForm] = useState({ + exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' + }); + const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor } + const toggleCVEExpand = (cveId) => { setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); }; @@ -309,6 +319,19 @@ export default function App() { } }; + const fetchArcherTickets = async () => { + try { + const response = await fetch(`${API_BASE}/archer-tickets`, { + credentials: 'include' + }); + if (!response.ok) throw new Error('Failed to fetch Archer tickets'); + const data = await response.json(); + setArcherTickets(data); + } catch (err) { + console.error('Error fetching Archer tickets:', err); + } + }; + const fetchDocuments = async (cveId, vendor) => { const key = `${cveId}-${vendor}`; if (cveDocuments[key]) return; @@ -745,12 +768,98 @@ export default function App() { setShowAddTicket(true); }; + // ========== ARCHER TICKET HANDLERS ========== + + const handleAddArcherTicket = async (e) => { + e.preventDefault(); + try { + const response = await fetch(`${API_BASE}/archer-tickets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(archerTicketForm) + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to create Archer ticket'); + } + alert('Archer ticket added successfully!'); + setShowAddArcherTicket(false); + setAddArcherTicketContext(null); + setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); + fetchArcherTickets(); + } catch (err) { + alert(`Error: ${err.message}`); + } + }; + + const handleEditArcherTicket = (ticket) => { + setEditingArcherTicket(ticket); + setArcherTicketForm({ + exc_number: ticket.exc_number, + archer_url: ticket.archer_url || '', + status: ticket.status, + cve_id: ticket.cve_id, + vendor: ticket.vendor + }); + setShowEditArcherTicket(true); + }; + + const handleUpdateArcherTicket = async (e) => { + e.preventDefault(); + try { + const response = await fetch(`${API_BASE}/archer-tickets/${editingArcherTicket.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + exc_number: archerTicketForm.exc_number, + archer_url: archerTicketForm.archer_url, + status: archerTicketForm.status + }) + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update Archer ticket'); + } + alert('Archer ticket updated!'); + setShowEditArcherTicket(false); + setEditingArcherTicket(null); + setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); + fetchArcherTickets(); + } catch (err) { + alert(`Error: ${err.message}`); + } + }; + + const handleDeleteArcherTicket = async (ticket) => { + if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return; + try { + const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, { + method: 'DELETE', + credentials: 'include' + }); + if (!response.ok) throw new Error('Failed to delete Archer ticket'); + alert('Archer ticket deleted'); + fetchArcherTickets(); + } catch (err) { + alert(`Error: ${err.message}`); + } + }; + + const openAddArcherTicketForCVE = (cve_id, vendor) => { + setAddArcherTicketContext({ cve_id, vendor }); + setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor }); + setShowAddArcherTicket(true); + }; + // Fetch CVEs from API when authenticated useEffect(() => { if (isAuthenticated) { fetchCVEs(); fetchVendors(); fetchJiraTickets(); + fetchArcherTickets(); fetchKnowledgeBaseArticles(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1337,6 +1446,151 @@ export default function App() { )} + {/* Add Archer Ticket Modal */} + {showAddArcherTicket && ( +
No active Archer tickets
+