11 Commits

Author SHA1 Message Date
c89404cf26 Add CVE list pagination to prevent endless scrolling
Shows 5 CVEs by default with 'Show 5 more' and 'Show all' controls.
Resets to 5 when filters or search change. Collapses back when fully expanded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:37:44 -07:00
af7a5becef Merge feature/archer: Add Archer Risk Acceptance Tickets 2026-02-23 11:08:28 -07:00
7145117518 Fix: Correct database filename in Archer tickets migration
Changed cve_tracker.db to cve_database.db to match server.js configuration.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 15:14:29 -07:00
30739dc162 Add Archer Risk Acceptance Tickets feature
- Add archer_tickets table with EXC number, Archer URL, status, CVE, and vendor
- Create backend routes for CRUD operations on Archer tickets
- Add right panel section displaying active Archer tickets
- Implement modals for creating and editing Archer tickets
- Validate EXC number format (EXC-XXXX)
- Support statuses: Draft, Open, Under Review, Accepted
- Purple theme (#8B5CF6) to distinguish from JIRA tickets
- Role-based access control for create/edit/delete operations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 15:07:07 -07:00
b0d2f915bd added migration and feature set for archer ticekts 2026-02-18 15:02:25 -07:00
112eb8dac1 added .md to global 2026-02-17 08:56:10 -07:00
3b37646b6d Fixed issue with upload doctype 2026-02-17 08:52:26 -07:00
241ff16bb4 Fix: Allow iframe embedding from frontend origin using CSP frame-ancestors 2026-02-13 11:14:59 -07:00
0e89251bac Fix: Change X-Frame-Options to SAMEORIGIN to allow PDF iframe embedding 2026-02-13 10:50:37 -07:00
fa9f4229a6 Add PDF inline preview support to knowledge base viewer 2026-02-13 10:46:32 -07:00
eea226a9d5 Fix: Add user to useAuth destructuring for knowledge base panel 2026-02-13 10:38:33 -07:00
6 changed files with 681 additions and 16 deletions

View File

@@ -0,0 +1,50 @@
// Migration: Add archer_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 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!');
});

View File

@@ -0,0 +1,223 @@
// routes/archerTickets.js
const express = require('express');
const { requireAuth, requireRole } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
// Validation helpers
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
function isValidCveId(cveId) {
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
}
function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
}
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;

View File

@@ -40,12 +40,27 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// POST /api/knowledge-base/upload - Upload new document // POST /api/knowledge-base/upload - Upload new document
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), upload.single('file'), async (req, res) => { router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
console.error('[KB Upload] Multer error:', err);
return res.status(400).json({ error: err.message || 'File upload failed' });
}
next();
});
}, async (req, res) => {
console.log('[KB Upload] Request received:', {
hasFile: !!req.file,
body: req.body,
contentType: req.headers['content-type']
});
const uploadedFile = req.file; const uploadedFile = req.file;
const { title, description, category } = req.body; const { title, description, category } = req.body;
// Validate required fields // Validate required fields
if (!title || !title.trim()) { if (!title || !title.trim()) {
console.error('[KB Upload] Error: Title is missing');
if (uploadedFile) fs.unlinkSync(uploadedFile.path); if (uploadedFile) fs.unlinkSync(uploadedFile.path);
return res.status(400).json({ error: 'Title is required' }); return res.status(400).json({ error: 'Title is required' });
} }
@@ -241,6 +256,9 @@ function createKnowledgeBaseRouter(db, upload) {
res.setHeader('Content-Type', contentType); res.setHeader('Content-Type', contentType);
// Use inline instead of attachment to allow browser to display // Use inline instead of attachment to allow browser to display
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`); res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
// Allow iframe embedding from frontend origin
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
res.sendFile(row.file_path); res.sendFile(row.file_path);
}); });
}); });

View File

@@ -20,6 +20,7 @@ const logAudit = require('./helpers/auditLog');
const createNvdLookupRouter = require('./routes/nvdLookup'); const createNvdLookupRouter = require('./routes/nvdLookup');
const createWeeklyReportsRouter = require('./routes/weeklyReports'); const createWeeklyReportsRouter = require('./routes/weeklyReports');
const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
const createArcherTicketsRouter = require('./routes/archerTickets');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -34,7 +35,7 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
// Allowed file extensions for document uploads (documents only, no executables) // Allowed file extensions for document uploads (documents only, no executables)
const ALLOWED_EXTENSIONS = new Set([ const ALLOWED_EXTENSIONS = new Set([
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
'.txt', '.csv', '.log', '.msg', '.eml', '.txt', '.md', '.csv', '.log', '.msg', '.eml',
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.odt', '.ods', '.odp',
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml', '.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
@@ -96,7 +97,7 @@ app.use((req, res, next) => {
// Security headers // Security headers
app.use((req, res, next) => { app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
@@ -108,7 +109,11 @@ app.use(cors({
origin: CORS_ORIGINS, origin: CORS_ORIGINS,
credentials: true credentials: true
})); }));
app.use(express.json({ limit: '1mb' })); // Only parse JSON for requests with application/json content type
app.use(express.json({
limit: '1mb',
type: 'application/json'
}));
app.use(cookieParser()); app.use(cookieParser());
app.use('/uploads', express.static('uploads', { app.use('/uploads', express.static('uploads', {
dotfiles: 'deny', dotfiles: 'deny',
@@ -175,6 +180,9 @@ app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view) // Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload)); 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 ========== // ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users) // Get all CVEs with optional filters (authenticated users)

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm'; import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu'; import UserMenu from './components/UserMenu';
@@ -158,7 +158,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() { export default function App() {
const { isAuthenticated, loading: authLoading, canWrite, isAdmin } = useAuth(); const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
@@ -200,6 +200,7 @@ export default function App() {
const [editNvdError, setEditNvdError] = useState(null); const [editNvdError, setEditNvdError] = useState(null);
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false); const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
const [expandedCVEs, setExpandedCVEs] = useState({}); const [expandedCVEs, setExpandedCVEs] = useState({});
const [visibleCount, setVisibleCount] = useState(5);
const [jiraTickets, setJiraTickets] = useState([]); const [jiraTickets, setJiraTickets] = useState([]);
const [showAddTicket, setShowAddTicket] = useState(false); const [showAddTicket, setShowAddTicket] = useState(false);
const [showEditTicket, setShowEditTicket] = useState(false); const [showEditTicket, setShowEditTicket] = useState(false);
@@ -210,6 +211,16 @@ export default function App() {
// For adding ticket from within a CVE card // For adding ticket from within a CVE card
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor } 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) => { const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
}; };
@@ -309,6 +320,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 fetchDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`; const key = `${cveId}-${vendor}`;
if (cveDocuments[key]) return; if (cveDocuments[key]) return;
@@ -745,12 +769,98 @@ export default function App() {
setShowAddTicket(true); 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 // Fetch CVEs from API when authenticated
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
fetchCVEs(); fetchCVEs();
fetchVendors(); fetchVendors();
fetchJiraTickets(); fetchJiraTickets();
fetchArcherTickets();
fetchKnowledgeBaseArticles(); fetchKnowledgeBaseArticles();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -760,6 +870,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
fetchCVEs(); fetchCVEs();
setVisibleCount(5);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]); }, [searchQuery, selectedVendor, selectedSeverity]);
@@ -1337,6 +1448,151 @@ export default function App() {
</div> </div>
)} )}
{/* Add Archer Ticket Modal */}
{showAddArcherTicket && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-purple-400 font-mono">Add Archer Risk Ticket</h2>
<button onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
<XCircle className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleAddArcherTicket} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
<input
type="text"
required
placeholder="EXC-5754"
value={archerTicketForm.exc_number}
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
<input
type="url"
placeholder="https://archer.example.com/..."
value={archerTicketForm.archer_url}
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
<input
type="text"
required
placeholder="CVE-2024-1234"
value={archerTicketForm.cve_id}
onChange={(e) => setArcherTicketForm({...archerTicketForm, cve_id: e.target.value.toUpperCase()})}
className="intel-input w-full"
readOnly={!!addArcherTicketContext}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
<input
type="text"
required
placeholder="Vendor name"
value={archerTicketForm.vendor}
onChange={(e) => setArcherTicketForm({...archerTicketForm, vendor: e.target.value})}
className="intel-input w-full"
readOnly={!!addArcherTicketContext}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
<select
value={archerTicketForm.status}
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
className="intel-input w-full"
>
<option value="Draft">Draft</option>
<option value="Open">Open</option>
<option value="Under Review">Under Review</option>
<option value="Accepted">Accepted</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">
Create Ticket
</button>
<button type="button" onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Edit Archer Ticket Modal */}
{showEditArcherTicket && editingArcherTicket && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-purple-400 font-mono">Edit Archer Risk Ticket</h2>
<button onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
{editingArcherTicket.cve_id} / {editingArcherTicket.vendor}
</div>
<form onSubmit={handleUpdateArcherTicket} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
<input
type="text"
required
value={archerTicketForm.exc_number}
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
<input
type="url"
value={archerTicketForm.archer_url}
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
<select
value={archerTicketForm.status}
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
className="intel-input w-full"
>
<option value="Draft">Draft</option>
<option value="Open">Open</option>
<option value="Under Review">Under Review</option>
<option value="Accepted">Accepted</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">
Save Changes
</button>
<button type="button" onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Three Column Layout */} {/* Three Column Layout */}
<div className="grid grid-cols-12 gap-6"> <div className="grid grid-cols-12 gap-6">
{/* LEFT PANEL - Wiki/Knowledge Base */} {/* LEFT PANEL - Wiki/Knowledge Base */}
@@ -1575,7 +1831,7 @@ export default function App() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => { {Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
const isCVEExpanded = expandedCVEs[cveId]; const isCVEExpanded = expandedCVEs[cveId];
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 }; const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
const highestSeverity = vendorEntries.reduce((highest, entry) => { const highestSeverity = vendorEntries.reduce((highest, entry) => {
@@ -1847,6 +2103,40 @@ export default function App() {
</div> </div>
); );
})} })}
{/* Show more / pagination footer */}
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
<div className="flex items-center justify-between pt-2">
<span className="text-gray-500 font-mono text-xs">
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
</span>
<div className="flex gap-2">
<button
onClick={() => setVisibleCount(v => v + 5)}
className="intel-button intel-button-primary text-xs px-3 py-1"
>
Show 5 more
</button>
<button
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Show all
</button>
</div>
</div>
)}
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
<div className="flex justify-end pt-2">
<button
onClick={() => setVisibleCount(5)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Collapse
</button>
</div>
)}
</div> </div>
)} )}
@@ -1993,6 +2283,70 @@ export default function App() {
)} )}
</div> </div>
</div> </div>
{/* Archer Risk Acceptance Tickets */}
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
<div className="flex justify-between items-center mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
<Shield className="w-5 h-5" />
Archer Risk Tickets
</h2>
{canWrite() && (
<button
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
{archerTickets.filter(t => t.status !== 'Accepted').length}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
<div className="flex items-start justify-between gap-2 mb-1">
<a
href={ticket.archer_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
>
{ticket.exc_number}
</a>
{canWrite() && (
<div className="flex gap-1">
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
<div className="text-xs text-gray-400">{ticket.vendor}</div>
<div className="mt-2">
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
{ticket.status}
</span>
</div>
</div>
))}
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
</div>
)}
</div>
</div>
</div> </div>
{/* End Right Panel */} {/* End Right Panel */}

View File

@@ -191,15 +191,27 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
{/* PDF */} {/* PDF */}
{isPDF && ( {isPDF && (
<div className="text-center py-12"> <div className="w-full" style={{ height: '700px' }}>
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} /> <iframe
<p className="mb-4" style={{ color: '#94A3B8' }}> src={`${API_BASE}/knowledge-base/${article.id}/content`}
PDF Preview not available. Click the download button to view this file. title={article.title}
</p> className="w-full h-full rounded"
<button onClick={handleDownload} className="intel-button intel-button-success"> style={{
<Download className="w-4 h-4 mr-2" /> border: '1px solid rgba(14, 165, 233, 0.3)',
Download PDF background: 'rgba(15, 23, 42, 0.8)'
</button> }}
>
<div className="text-center py-12">
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} />
<p className="mb-4" style={{ color: '#94A3B8' }}>
Your browser doesn't support PDF preview. Click the download button to view this file.
</p>
<button onClick={handleDownload} className="intel-button intel-button-success">
<Download className="w-4 h-4 mr-2" />
Download PDF
</button>
</div>
</iframe>
</div> </div>
)} )}