Compare commits
68 Commits
da109a6f8b
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a2c56a11f | |||
| 89b1f57ef4 | |||
| 6bf6371e51 | |||
| 4d472b0aef | |||
| 887d11610e | |||
| 1520cc994b | |||
| 906066c7fa | |||
| b58bd0650a | |||
| ae04bc981e | |||
| 7314dc16cb | |||
| 602c75bf24 | |||
| 706ef19872 | |||
| 8392124df5 | |||
| fbe4333e9b | |||
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a | |||
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 | |||
| d3806e8ce3 | |||
| 931c42faeb | |||
| ea3b72db5c | |||
| d63e7cc9b9 | |||
| 37e183543a | |||
| 337ffe6f35 | |||
| 08c8c8a2a1 | |||
| 4ed7721a71 | |||
| 3fb20c147d | |||
| f2e6069c08 | |||
| c89404cf26 | |||
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd | |||
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 | |||
| 0d67a99c7e | |||
| bf3d01becf | |||
| 9384ded04f | |||
| 0c9c3b5514 | |||
| 4a50cd100b | |||
| c22a3a70ab | |||
| 626d0cac3a | |||
| ba4d16396c | |||
| 83d944fa70 | |||
| 26abd55e0f | |||
| eae4594baf | |||
| 84803a353e | |||
| d520c4ae41 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -37,9 +37,12 @@ frontend.pid
|
||||
|
||||
# Temporary files
|
||||
backend/uploads/temp/
|
||||
claude.md
|
||||
claude_status.md
|
||||
feature_request*.md
|
||||
|
||||
# AI tooling config
|
||||
.claude/
|
||||
ai_notes.md
|
||||
ai_status.md
|
||||
backend/add_vendor_to_documents.js
|
||||
backend/fix_multivendor_constraint.js
|
||||
backend/server.js-backup
|
||||
|
||||
290
DESIGN_SYSTEM.md
Normal file
290
DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# CVE Intelligence Dashboard - Design System Reference
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
```css
|
||||
--intel-darkest: #0F172A /* Slate 900 - Deepest background */
|
||||
--intel-dark: #1E293B /* Slate 800 - Card backgrounds */
|
||||
--intel-medium: #334155 /* Slate 700 - Elevated elements */
|
||||
```
|
||||
|
||||
### Accent & Status Colors
|
||||
```css
|
||||
--intel-accent: #0EA5E9 /* Sky Blue - Primary accent, links, interactive elements */
|
||||
--intel-warning: #F59E0B /* Amber - Warnings, high severity, open tickets */
|
||||
--intel-danger: #EF4444 /* Red - Critical severity, destructive actions */
|
||||
--intel-success: #10B981 /* Emerald - Success states, low severity, confirmations */
|
||||
```
|
||||
|
||||
### Text Colors
|
||||
```css
|
||||
--text-primary: #F8FAFC /* Slate 50 - Primary text */
|
||||
--text-secondary: #E2E8F0 /* Slate 200 - Secondary text */
|
||||
--text-tertiary: #CBD5E1 /* Slate 300 - Labels, metadata */
|
||||
--text-muted: #94A3B8 /* Slate 400 - Placeholders, disabled */
|
||||
```
|
||||
|
||||
### Severity Badge Colors
|
||||
| Severity | Border | Background | Text | Glow Dot |
|
||||
|----------|--------|------------|------|----------|
|
||||
| **Critical** | `#EF4444` | `rgba(239, 68, 68, 0.25)` | `#FCA5A5` | `#EF4444` |
|
||||
| **High** | `#F59E0B` | `rgba(245, 158, 11, 0.25)` | `#FCD34D` | `#F59E0B` |
|
||||
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
||||
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
||||
|
||||
## Layout Structure
|
||||
|
||||
### Three-Column Grid Layout
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HEADER & STATS BAR │
|
||||
│ CVE INTEL | [Stats: Total, Entries, Tickets, Critical] │
|
||||
├──────────────┬─────────────────────────┬────────────────────┤
|
||||
│ │ │ │
|
||||
│ LEFT PANEL │ CENTER PANEL │ RIGHT PANEL │
|
||||
│ (3 cols) │ (6 cols) │ (3 cols) │
|
||||
│ │ │ │
|
||||
│ Knowledge │ Quick CVE Lookup │ Calendar │
|
||||
│ Base │ Search & Filters │ Widget │
|
||||
│ - Wiki │ CVE Results List │ │
|
||||
│ - Docs │ - Expandable cards │ Open Tickets │
|
||||
│ - Policies │ - Vendor entries │ - Compact list │
|
||||
│ - Guides │ - Documents │ - Quick stats │
|
||||
│ │ - JIRA tickets │ │
|
||||
│ │ │ │
|
||||
└──────────────┴─────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### Responsive Breakpoints
|
||||
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
||||
- **Tablet/Mobile**: Stacked single column
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### Stat Cards
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Border: 2px solid [accent-color]
|
||||
Border Radius: 0.5rem
|
||||
Padding: 1rem
|
||||
Top Accent Line: 2px gradient, 0 0 8px glow
|
||||
Shadow: 0 4px 16px rgba(0, 0, 0, 0.5)
|
||||
Hover: translateY(-2px), enhanced shadow
|
||||
```
|
||||
|
||||
### Intel Cards (Main Content)
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Border: 2px solid rgba(14, 165, 233, 0.4)
|
||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6), subtle glow
|
||||
Hover: Enhanced border (0.5 opacity), lift effect
|
||||
```
|
||||
|
||||
### Buttons
|
||||
```css
|
||||
/* Primary */
|
||||
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))
|
||||
Border: 1px solid #0EA5E9
|
||||
Color: #38BDF8
|
||||
Text Shadow: 0 0 6px rgba(14, 165, 233, 0.2)
|
||||
|
||||
/* Hover State */
|
||||
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.25), rgba(14, 165, 233, 0.2))
|
||||
Shadow: 0 0 20px rgba(14, 165, 233, 0.25)
|
||||
Transform: translateY(-1px)
|
||||
Ripple Effect: 300px radial on click
|
||||
```
|
||||
|
||||
### Input Fields
|
||||
```css
|
||||
Background: rgba(30, 41, 59, 0.6)
|
||||
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||
Font: 'JetBrains Mono', monospace
|
||||
Focus: border #0EA5E9, ring 2px rgba(14, 165, 233, 0.15)
|
||||
```
|
||||
|
||||
### Badges (Status/Severity)
|
||||
```css
|
||||
Display: inline-flex
|
||||
Align Items: center
|
||||
Gap: 0.5rem
|
||||
Border: 2px solid [severity-color]
|
||||
Border Radius: 0.375rem
|
||||
Padding: 0.375rem 0.875rem
|
||||
Font: 'JetBrains Mono', 0.75rem, 700, uppercase
|
||||
Letter Spacing: 0.5px
|
||||
Glow Dot: 8px circle with pulse animation
|
||||
```
|
||||
|
||||
## Interactions & Animations
|
||||
|
||||
### Hover Effects
|
||||
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
||||
- **Buttons**: Radial ripple expand (300px), slight lift
|
||||
- **List Items**: Border color shift, background lighten
|
||||
|
||||
### Animations
|
||||
```css
|
||||
/* Pulse Glow (for dots) */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
/* Scan Line */
|
||||
@keyframes scan {
|
||||
0%, 100% { transform: translateY(-100%); opacity: 0; }
|
||||
50% { transform: translateY(2000%); opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Spin (loading) */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### Transitions
|
||||
```css
|
||||
Standard: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
Fast: all 0.2s ease
|
||||
Ripple: width/height 0.5s
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Families
|
||||
```css
|
||||
Primary (UI): 'Outfit', system-ui, sans-serif
|
||||
Monospace (Data/Code): 'JetBrains Mono', monospace
|
||||
```
|
||||
|
||||
### Font Sizes & Weights
|
||||
```css
|
||||
/* Headings */
|
||||
h1: 2.5rem (40px), 700, monospace
|
||||
h2: 1.125rem (18px), 600, uppercase, tracking-wider
|
||||
h3: 1.125rem (18px), 600
|
||||
|
||||
/* Body */
|
||||
Body: 0.875rem (14px), 400
|
||||
Small: 0.75rem (12px), 400
|
||||
Labels: 0.75rem (12px), 500, uppercase, tracking-wider
|
||||
```
|
||||
|
||||
### Text Shadows (Headings)
|
||||
```css
|
||||
Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0.15)
|
||||
Badge Text: 0 0 8px rgba([color], 0.5)
|
||||
```
|
||||
|
||||
## Visual Effects
|
||||
|
||||
### Shadows
|
||||
```css
|
||||
/* Card Elevations */
|
||||
Level 1: 0 2px 6px rgba(0, 0, 0, 0.3)
|
||||
Level 2: 0 4px 12px rgba(0, 0, 0, 0.4)
|
||||
Level 3: 0 8px 24px rgba(0, 0, 0, 0.6)
|
||||
|
||||
/* Glows */
|
||||
Subtle: 0 0 12px rgba([color], 0.12)
|
||||
Medium: 0 0 20px rgba([color], 0.15)
|
||||
Strong: 0 0 28px rgba([color], 0.25)
|
||||
|
||||
/* Inset Highlights */
|
||||
Top: inset 0 1px 0 rgba(14, 165, 233, 0.15)
|
||||
Recessed: inset 0 2px 4px rgba(0, 0, 0, 0.3)
|
||||
```
|
||||
|
||||
### Border Styles
|
||||
```css
|
||||
/* Standard Cards */
|
||||
Border: 1.5-2px solid rgba(14, 165, 233, 0.3-0.4)
|
||||
|
||||
/* Accent Panels */
|
||||
Left Border: 3px solid [accent-color]
|
||||
|
||||
/* Vendor/Nested Cards */
|
||||
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||
```
|
||||
|
||||
### Gradients
|
||||
```css
|
||||
/* Backgrounds */
|
||||
Card: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Nested: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9))
|
||||
|
||||
/* Accent Lines */
|
||||
Top Bar: linear-gradient(90deg, transparent, [color], transparent)
|
||||
|
||||
/* Grid Background */
|
||||
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
|
||||
Size: 20px × 20px
|
||||
```
|
||||
|
||||
## Specific Component Patterns
|
||||
|
||||
### Wiki/Knowledge Base Entry
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||
Border: 1px solid rgba(16, 185, 129, 0.25)
|
||||
Padding: 0.75rem
|
||||
Cursor: pointer
|
||||
Hover: border-color shift to rgba(16, 185, 129, 0.4)
|
||||
```
|
||||
|
||||
### Calendar Widget
|
||||
```css
|
||||
Day Cells:
|
||||
- Text: white, font-mono, 0.75rem
|
||||
- Hover: bg rgba(14, 165, 233, 0.2)
|
||||
- Current Day: bg rgba(14, 165, 233, 0.3), border 1px #0EA5E9
|
||||
- Other Month: text rgba(148, 163, 184, 0.5)
|
||||
```
|
||||
|
||||
### Ticket Cards (Compact)
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||
Border: 1px solid rgba(245, 158, 11, 0.25)
|
||||
Padding: 0.5rem
|
||||
Status Badge: Reduced size (0.65rem, 0.25rem padding)
|
||||
Glow Dot: 6px diameter
|
||||
```
|
||||
|
||||
### CVE Expandable Cards
|
||||
```css
|
||||
Header: Clickable, cursor pointer
|
||||
Collapsed: Show summary (severity, vendor count, doc count)
|
||||
Expanded: Full description, metadata, vendor entries
|
||||
Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
|
||||
Vendor Cards: Nested with reduced opacity borders
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Contrast Ratios
|
||||
- Primary text on dark: 18.5:1 (AAA)
|
||||
- Secondary text on dark: 12.3:1 (AAA)
|
||||
- Accent colors: All meet WCAG AA minimum
|
||||
|
||||
### Interactive States
|
||||
- Focus rings: 2px solid accent color
|
||||
- Hover: Visible border/background changes
|
||||
- Active: Transform feedback
|
||||
|
||||
### Typography
|
||||
- Minimum size: 12px (0.75rem)
|
||||
- Line height: 1.5 for body text
|
||||
- Letter spacing: Generous for uppercase labels
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
||||
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
||||
3. **Refined Depth**: Layers and elevation without harsh neon
|
||||
4. **Purposeful Color**: Accent colors convey meaning (status, severity)
|
||||
5. **Smooth Interactions**: Polished micro-interactions and transitions
|
||||
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
||||
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
||||
|
||||
7
Ivanti_config_template.ini
Normal file
7
Ivanti_config_template.ini
Normal file
@@ -0,0 +1,7 @@
|
||||
[platform]
|
||||
url = https://platform4.risksense.com
|
||||
api_ver = /api/v1
|
||||
# PROD 1550 | UAT 1551
|
||||
client_id = <pick 1550 or 1551>
|
||||
[secrets]
|
||||
api_key = <your API key here>
|
||||
211
WEEKLY_REPORT_FEATURE.md
Normal file
211
WEEKLY_REPORT_FEATURE.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Weekly Vulnerability Report Upload Feature
|
||||
|
||||
## Overview
|
||||
|
||||
A new feature has been added to the CVE Dashboard that allows users to upload their weekly vulnerability reports in Excel format (.xlsx) and automatically process them to split multiple CVE IDs into separate rows for easier filtering and analysis.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Backend Changes
|
||||
|
||||
1. **Database Migration** (`backend/migrations/add_weekly_reports_table.js`)
|
||||
- Created `weekly_reports` table to store report metadata
|
||||
- Tracks upload date, file paths, row counts, and which report is current
|
||||
- Indexed for fast queries
|
||||
|
||||
2. **Excel Processor** (`backend/helpers/excelProcessor.js`)
|
||||
- Executes Python script via Node.js child_process
|
||||
- Parses row counts from Python output
|
||||
- Handles errors, timeouts (30 seconds), and validation
|
||||
|
||||
3. **API Routes** (`backend/routes/weeklyReports.js`)
|
||||
- `POST /api/weekly-reports/upload` - Upload and process Excel file
|
||||
- `GET /api/weekly-reports` - List all reports
|
||||
- `GET /api/weekly-reports/:id/download/:type` - Download original or processed file
|
||||
- `DELETE /api/weekly-reports/:id` - Delete report (admin only)
|
||||
|
||||
4. **Python Script** (`backend/scripts/split_cve_report.py`)
|
||||
- Moved from ~/Documents to backend/scripts
|
||||
- Splits comma-separated CVE IDs into separate rows
|
||||
- Duplicates device/IP data for each CVE
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
1. **Weekly Report Modal** (`frontend/src/components/WeeklyReportModal.js`)
|
||||
- Phase-based UI: idle → uploading → processing → success
|
||||
- File upload with .xlsx validation
|
||||
- Display existing reports with current report indicator (★)
|
||||
- Download buttons for both original and processed files
|
||||
|
||||
2. **App.js Integration**
|
||||
- Added "Weekly Report" button next to NVD Sync button
|
||||
- State management for modal visibility
|
||||
- Modal rendering
|
||||
|
||||
## How to Use
|
||||
|
||||
### Starting the Application
|
||||
|
||||
1. **Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
node server.js
|
||||
```
|
||||
|
||||
2. **Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
### Using the Feature
|
||||
|
||||
1. **Access the Feature**
|
||||
- Login as an editor or admin user
|
||||
- Look for the "Weekly Report" button in the top header (next to "NVD Sync")
|
||||
|
||||
2. **Upload a Report**
|
||||
- Click the "Weekly Report" button
|
||||
- Click "Choose File" and select your .xlsx file
|
||||
- Click "Upload & Process"
|
||||
- Wait for processing to complete (usually 5-10 seconds)
|
||||
|
||||
3. **Download Processed Report**
|
||||
- After upload succeeds, you'll see row counts (e.g., "45 → 67 rows")
|
||||
- Click "Download Processed" to get the split version
|
||||
- The current week's report is marked with a ★ star icon
|
||||
|
||||
4. **Access Previous Reports**
|
||||
- All previous reports are listed below the upload section
|
||||
- Click the download icons to get original or processed versions
|
||||
- Reports are labeled as "This week's report", "Last week's report", or by date
|
||||
|
||||
### What the Processing Does
|
||||
|
||||
**Before Processing:**
|
||||
| HOSTNAME | IP | CVE ID |
|
||||
|----------|------------|---------------------------|
|
||||
| server01 | 10.0.0.1 | CVE-2024-1234, CVE-2024-5678 |
|
||||
|
||||
**After Processing:**
|
||||
| HOSTNAME | IP | CVE ID |
|
||||
|----------|------------|---------------------------|
|
||||
| server01 | 10.0.0.1 | CVE-2024-1234 |
|
||||
| server01 | 10.0.0.1 | CVE-2024-5678 |
|
||||
|
||||
Each CVE now has its own row, making it easy to:
|
||||
- Sort by CVE ID
|
||||
- Filter for specific CVEs
|
||||
- Research CVEs one by one per device
|
||||
|
||||
## File Locations
|
||||
|
||||
### New Files Created
|
||||
|
||||
```
|
||||
backend/
|
||||
scripts/
|
||||
split_cve_report.py # Python script for CVE splitting
|
||||
requirements.txt # Python dependencies
|
||||
routes/
|
||||
weeklyReports.js # API endpoints
|
||||
helpers/
|
||||
excelProcessor.js # Python integration
|
||||
migrations/
|
||||
add_weekly_reports_table.js # Database migration
|
||||
uploads/
|
||||
weekly_reports/ # Uploaded and processed files
|
||||
|
||||
frontend/
|
||||
src/
|
||||
components/
|
||||
WeeklyReportModal.js # Upload modal UI
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
backend/
|
||||
server.js # Added route mounting
|
||||
|
||||
frontend/
|
||||
src/
|
||||
App.js # Added button and modal
|
||||
```
|
||||
|
||||
## Security & Permissions
|
||||
|
||||
- **Upload**: Requires editor or admin role
|
||||
- **Download**: Any authenticated user
|
||||
- **Delete**: Admin only
|
||||
- **File Validation**: Only .xlsx files accepted, 10MB limit
|
||||
- **Audit Logging**: All uploads, downloads, and deletions are logged
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Issues
|
||||
|
||||
**Python not found:**
|
||||
```bash
|
||||
# Install Python 3
|
||||
sudo apt-get install python3
|
||||
```
|
||||
|
||||
**Missing dependencies:**
|
||||
```bash
|
||||
# Install pandas and openpyxl
|
||||
pip3 install pandas openpyxl
|
||||
```
|
||||
|
||||
**Port already in use:**
|
||||
```bash
|
||||
# Find and kill process using port 3001
|
||||
lsof -i :3001
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Frontend Issues
|
||||
|
||||
**Button not visible:**
|
||||
- Make sure you're logged in as editor or admin
|
||||
- Viewer role cannot upload reports
|
||||
|
||||
**Upload fails:**
|
||||
- Check file is .xlsx format (not .xls or .csv)
|
||||
- Ensure file has "Vulnerabilities" sheet with "CVE ID" column
|
||||
- Check file size is under 10MB
|
||||
|
||||
**Processing timeout:**
|
||||
- Large files (10,000+ rows) may timeout
|
||||
- Try reducing file size or increase timeout in `excelProcessor.js`
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Backend starts without errors
|
||||
- [x] Frontend compiles successfully
|
||||
- [x] Database migration completed
|
||||
- [x] Python dependencies installed
|
||||
- [ ] Upload .xlsx file (manual test in browser)
|
||||
- [ ] Verify processed file has split CVEs (manual test)
|
||||
- [ ] Download original and processed files (manual test)
|
||||
- [ ] Verify current report marked with star (manual test)
|
||||
- [ ] Test as viewer - button should be hidden (manual test)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Possible improvements:
|
||||
- Progress bar during Python processing
|
||||
- Email notifications when processing completes
|
||||
- Scheduled automatic uploads
|
||||
- Report comparison (diff between weeks)
|
||||
- Export to other formats (CSV, JSON)
|
||||
- Bulk delete old reports
|
||||
- Report validation before upload
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check the troubleshooting section above
|
||||
2. Review audit logs for error details
|
||||
3. Check browser console for frontend errors
|
||||
4. Review backend server logs for API errors
|
||||
838
architecture.excalidraw
Normal file
838
architecture.excalidraw
Normal file
@@ -0,0 +1,838 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "https://excalidraw.com",
|
||||
"elements": [
|
||||
{
|
||||
"id": "title-text",
|
||||
"type": "text",
|
||||
"x": 400,
|
||||
"y": 30,
|
||||
"width": 400,
|
||||
"height": 45,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1971c2",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "CVE Dashboard Architecture",
|
||||
"fontSize": 36,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "top",
|
||||
"baseline": 32,
|
||||
"containerId": null,
|
||||
"originalText": "CVE Dashboard Architecture"
|
||||
},
|
||||
{
|
||||
"id": "users-box",
|
||||
"type": "ellipse",
|
||||
"x": 500,
|
||||
"y": 120,
|
||||
"width": 200,
|
||||
"height": 80,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1971c2",
|
||||
"backgroundColor": "#e7f5ff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 2,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "users-text"
|
||||
},
|
||||
{
|
||||
"id": "arrow-users-frontend",
|
||||
"type": "arrow"
|
||||
}
|
||||
],
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "users-text",
|
||||
"type": "text",
|
||||
"x": 505,
|
||||
"y": 145,
|
||||
"width": 190,
|
||||
"height": 30,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1971c2",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 3,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "Users\n(Admin/Editor/Viewer)",
|
||||
"fontSize": 16,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"baseline": 23,
|
||||
"containerId": "users-box",
|
||||
"originalText": "Users\n(Admin/Editor/Viewer)"
|
||||
},
|
||||
{
|
||||
"id": "frontend-box",
|
||||
"type": "rectangle",
|
||||
"x": 450,
|
||||
"y": 250,
|
||||
"width": 300,
|
||||
"height": 120,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1971c2",
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 4,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "frontend-text"
|
||||
},
|
||||
{
|
||||
"id": "arrow-users-frontend",
|
||||
"type": "arrow"
|
||||
},
|
||||
{
|
||||
"id": "arrow-frontend-backend",
|
||||
"type": "arrow"
|
||||
}
|
||||
],
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "frontend-text",
|
||||
"type": "text",
|
||||
"x": 455,
|
||||
"y": 255,
|
||||
"width": 290,
|
||||
"height": 110,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1971c2",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 5,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views",
|
||||
"fontSize": 14,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "middle",
|
||||
"baseline": 103,
|
||||
"containerId": "frontend-box",
|
||||
"originalText": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views"
|
||||
},
|
||||
{
|
||||
"id": "backend-box",
|
||||
"type": "rectangle",
|
||||
"x": 400,
|
||||
"y": 420,
|
||||
"width": 400,
|
||||
"height": 180,
|
||||
"angle": 0,
|
||||
"strokeColor": "#7048e8",
|
||||
"backgroundColor": "#d0bfff",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 6,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "backend-text"
|
||||
},
|
||||
{
|
||||
"id": "arrow-frontend-backend",
|
||||
"type": "arrow"
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-db",
|
||||
"type": "arrow"
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-storage",
|
||||
"type": "arrow"
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-nvd",
|
||||
"type": "arrow"
|
||||
}
|
||||
],
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "backend-text",
|
||||
"type": "text",
|
||||
"x": 405,
|
||||
"y": 425,
|
||||
"width": 390,
|
||||
"height": 170,
|
||||
"angle": 0,
|
||||
"strokeColor": "#7048e8",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 7,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports",
|
||||
"fontSize": 14,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "middle",
|
||||
"baseline": 163,
|
||||
"containerId": "backend-box",
|
||||
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports"
|
||||
},
|
||||
{
|
||||
"id": "db-box",
|
||||
"type": "rectangle",
|
||||
"x": 200,
|
||||
"y": 680,
|
||||
"width": 280,
|
||||
"height": 140,
|
||||
"angle": 0,
|
||||
"strokeColor": "#2f9e44",
|
||||
"backgroundColor": "#b2f2bb",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 8,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "db-text"
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-db",
|
||||
"type": "arrow"
|
||||
}
|
||||
],
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "db-text",
|
||||
"type": "text",
|
||||
"x": 205,
|
||||
"y": 685,
|
||||
"width": 270,
|
||||
"height": 130,
|
||||
"angle": 0,
|
||||
"strokeColor": "#2f9e44",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 9,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log",
|
||||
"fontSize": 14,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "middle",
|
||||
"baseline": 123,
|
||||
"containerId": "db-box",
|
||||
"originalText": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log"
|
||||
},
|
||||
{
|
||||
"id": "storage-box",
|
||||
"type": "rectangle",
|
||||
"x": 550,
|
||||
"y": 680,
|
||||
"width": 280,
|
||||
"height": 140,
|
||||
"angle": 0,
|
||||
"strokeColor": "#f08c00",
|
||||
"backgroundColor": "#ffec99",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 10,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "storage-text"
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-storage",
|
||||
"type": "arrow"
|
||||
}
|
||||
],
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "storage-text",
|
||||
"type": "text",
|
||||
"x": 555,
|
||||
"y": 685,
|
||||
"width": 270,
|
||||
"height": 130,
|
||||
"angle": 0,
|
||||
"strokeColor": "#f08c00",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 11,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames",
|
||||
"fontSize": 14,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "middle",
|
||||
"baseline": 123,
|
||||
"containerId": "storage-box",
|
||||
"originalText": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames"
|
||||
},
|
||||
{
|
||||
"id": "nvd-box",
|
||||
"type": "rectangle",
|
||||
"x": 900,
|
||||
"y": 420,
|
||||
"width": 220,
|
||||
"height": 100,
|
||||
"angle": 0,
|
||||
"strokeColor": "#e03131",
|
||||
"backgroundColor": "#ffc9c9",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 12,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "nvd-text"
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-nvd",
|
||||
"type": "arrow"
|
||||
}
|
||||
],
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "nvd-text",
|
||||
"type": "text",
|
||||
"x": 905,
|
||||
"y": 425,
|
||||
"width": 210,
|
||||
"height": 90,
|
||||
"angle": 0,
|
||||
"strokeColor": "#e03131",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 13,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup",
|
||||
"fontSize": 14,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"baseline": 83,
|
||||
"containerId": "nvd-box",
|
||||
"originalText": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup"
|
||||
},
|
||||
{
|
||||
"id": "arrow-users-frontend",
|
||||
"type": "arrow",
|
||||
"x": 600,
|
||||
"y": 200,
|
||||
"width": 0,
|
||||
"height": 50,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1971c2",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "round",
|
||||
"seed": 14,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[0, 0],
|
||||
[0, 50]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "users-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "frontend-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": false,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "arrow-frontend-backend",
|
||||
"type": "arrow",
|
||||
"x": 600,
|
||||
"y": 370,
|
||||
"width": 0,
|
||||
"height": 50,
|
||||
"angle": 0,
|
||||
"strokeColor": "#7048e8",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "round",
|
||||
"seed": 15,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[0, 0],
|
||||
[0, 50]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "frontend-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "backend-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": false,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-db",
|
||||
"type": "arrow",
|
||||
"x": 500,
|
||||
"y": 600,
|
||||
"width": -140,
|
||||
"height": 80,
|
||||
"angle": 0,
|
||||
"strokeColor": "#2f9e44",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "round",
|
||||
"seed": 16,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[0, 0],
|
||||
[-140, 0],
|
||||
[-140, 80]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "backend-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "db-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": true,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-storage",
|
||||
"type": "arrow",
|
||||
"x": 700,
|
||||
"y": 600,
|
||||
"width": 0,
|
||||
"height": 80,
|
||||
"angle": 0,
|
||||
"strokeColor": "#f08c00",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "round",
|
||||
"seed": 17,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[0, 0],
|
||||
[0, 80]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "backend-box",
|
||||
"focus": 0.5,
|
||||
"gap": 1
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "storage-box",
|
||||
"focus": 0.5,
|
||||
"gap": 1
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": false,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "arrow-backend-nvd",
|
||||
"type": "arrow",
|
||||
"x": 800,
|
||||
"y": 480,
|
||||
"width": 100,
|
||||
"height": 0,
|
||||
"angle": 0,
|
||||
"strokeColor": "#e03131",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "round",
|
||||
"seed": 18,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"points": [
|
||||
[0, 0],
|
||||
[100, 0]
|
||||
],
|
||||
"lastCommittedPoint": null,
|
||||
"startBinding": {
|
||||
"elementId": "backend-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"endBinding": {
|
||||
"elementId": "nvd-box",
|
||||
"focus": 0,
|
||||
"gap": 1
|
||||
},
|
||||
"startArrowhead": null,
|
||||
"endArrowhead": "arrow",
|
||||
"elbowed": false,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "label-http",
|
||||
"type": "text",
|
||||
"x": 610,
|
||||
"y": 390,
|
||||
"width": 100,
|
||||
"height": 20,
|
||||
"angle": 0,
|
||||
"strokeColor": "#7048e8",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 19,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "HTTP/REST API",
|
||||
"fontSize": 12,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"baseline": 17,
|
||||
"containerId": null,
|
||||
"originalText": "HTTP/REST API"
|
||||
},
|
||||
{
|
||||
"id": "label-https",
|
||||
"type": "text",
|
||||
"x": 820,
|
||||
"y": 460,
|
||||
"width": 60,
|
||||
"height": 20,
|
||||
"angle": 0,
|
||||
"strokeColor": "#e03131",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 20,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "HTTPS",
|
||||
"fontSize": 12,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"baseline": 17,
|
||||
"containerId": null,
|
||||
"originalText": "HTTPS"
|
||||
},
|
||||
{
|
||||
"id": "auth-note",
|
||||
"type": "text",
|
||||
"x": 100,
|
||||
"y": 250,
|
||||
"width": 280,
|
||||
"height": 80,
|
||||
"angle": 0,
|
||||
"strokeColor": "#495057",
|
||||
"backgroundColor": "#f8f9fa",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 21,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)",
|
||||
"fontSize": 12,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"baseline": 73,
|
||||
"containerId": null,
|
||||
"originalText": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)"
|
||||
},
|
||||
{
|
||||
"id": "features-note",
|
||||
"type": "text",
|
||||
"x": 900,
|
||||
"y": 580,
|
||||
"width": 280,
|
||||
"height": 120,
|
||||
"angle": 0,
|
||||
"strokeColor": "#495057",
|
||||
"backgroundColor": "#f8f9fa",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"seed": 22,
|
||||
"version": 1,
|
||||
"versionNonce": 1,
|
||||
"isDeleted": false,
|
||||
"boundElements": null,
|
||||
"updated": 1,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging",
|
||||
"fontSize": 12,
|
||||
"fontFamily": 1,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"baseline": 113,
|
||||
"containerId": null,
|
||||
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging"
|
||||
}
|
||||
],
|
||||
"appState": {
|
||||
"gridSize": null,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000
|
||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||
NVD_API_KEY=
|
||||
|
||||
# Ivanti / RiskSense API (platform4.risksense.com)
|
||||
# API key from your profile settings — does not expire like session cookies
|
||||
IVANTI_API_KEY=
|
||||
IVANTI_CLIENT_ID=1550
|
||||
IVANTI_FIRST_NAME=
|
||||
IVANTI_LAST_NAME=
|
||||
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||
IVANTI_SKIP_TLS=false
|
||||
|
||||
39
backend/migrate_jira_tickets.js
Normal file
39
backend/migrate_jira_tickets.js
Normal file
@@ -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!');
|
||||
});
|
||||
50
backend/migrations/add_archer_tickets_table.js
Normal file
50
backend/migrations/add_archer_tickets_table.js
Normal 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!');
|
||||
});
|
||||
78
backend/migrations/add_card_workflow_type.js
Normal file
78
backend/migrations/add_card_workflow_type.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// Migration: Add CARD to workflow_type CHECK constraint on ivanti_todo_queue
|
||||
// SQLite cannot ALTER a CHECK constraint, so this recreates the 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 add_card_workflow_type migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||
if (err) console.error('PRAGMA error:', err);
|
||||
});
|
||||
|
||||
db.run('BEGIN TRANSACTION', (err) => {
|
||||
if (err) { console.error('BEGIN error:', err); return; }
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE ivanti_todo_queue_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating new table:', err);
|
||||
else console.log('✓ ivanti_todo_queue_new created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'INSERT INTO ivanti_todo_queue_new SELECT * FROM ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error copying data:', err);
|
||||
else console.log('✓ Data copied');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||
if (err) console.error('Error dropping old table:', err);
|
||||
else console.log('✓ Old table dropped');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error renaming table:', err);
|
||||
else console.log('✓ Table renamed');
|
||||
}
|
||||
);
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ Index recreated');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('COMMIT', (err) => {
|
||||
if (err) console.error('COMMIT error:', err);
|
||||
else console.log('✓ Transaction committed');
|
||||
});
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON', () => {});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables
|
||||
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 Ivanti findings tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Cache table — single row holding the latest sync result
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating findings cache table:', err);
|
||||
else console.log('✓ ivanti_findings_cache table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) console.error('Error seeding findings cache row:', err);
|
||||
else console.log('✓ ivanti_findings_cache row seeded');
|
||||
});
|
||||
|
||||
// Notes table — one row per finding, persists across cache refreshes
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating finding notes table:', err);
|
||||
else console.log('✓ ivanti_finding_notes table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating notes index:', err);
|
||||
else console.log('✓ finding_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
37
backend/migrations/add_ivanti_sync_table.js
Normal file
37
backend/migrations/add_ivanti_sync_table.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Migration: Add ivanti_sync_state 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 Ivanti sync state migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
workflows_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ ivanti_sync_state table created');
|
||||
});
|
||||
|
||||
// Seed the single-row state record
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) console.error('Error seeding state row:', err);
|
||||
else console.log('✓ ivanti_sync_state row seeded');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// Migration: Add ivanti_todo_queue 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 ivanti_todo_queue migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ ivanti_todo_queue table created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ User+status index created');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✓ Migration statements queued');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
70
backend/migrations/add_knowledge_base_table.js
Normal file
70
backend/migrations/add_knowledge_base_table.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Migration: Add knowledge_base table for storing documentation and policies
|
||||
|
||||
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('Running migration: add_knowledge_base_table');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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 TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating knowledge_base table:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created knowledge_base table');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug
|
||||
ON knowledge_base(slug)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating slug index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on slug');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category
|
||||
ON knowledge_base(category)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating category index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on category');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at
|
||||
ON knowledge_base(created_at DESC)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating created_at index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on created_at');
|
||||
console.log('\nMigration completed successfully!');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Migration: Add ip_address column to ivanti_todo_queue
|
||||
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 add_todo_queue_ip_address migration...');
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue ADD COLUMN ip_address TEXT',
|
||||
(err) => {
|
||||
if (err) {
|
||||
// Column may already exist if migration was run before
|
||||
if (err.message.includes('duplicate column name')) {
|
||||
console.log('✓ ip_address column already exists, skipping');
|
||||
} else {
|
||||
console.error('Error adding column:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ ip_address column added');
|
||||
}
|
||||
db.close(() => console.log('Migration complete!'));
|
||||
}
|
||||
);
|
||||
223
backend/routes/archerTickets.js
Normal file
223
backend/routes/archerTickets.js
Normal 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;
|
||||
@@ -219,8 +219,13 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up expired sessions (can be called periodically)
|
||||
// Clean up expired sessions (admin only)
|
||||
router.post('/cleanup-sessions', async (req, res) => {
|
||||
// Basic auth check - require a valid session to call this
|
||||
const sessionId = req.cookies?.session_id;
|
||||
if (!sessionId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
|
||||
658
backend/routes/ivantiFindings.js
Normal file
658
backend/routes/ivantiFindings.js
Normal file
@@ -0,0 +1,658 @@
|
||||
// Ivanti / RiskSense Host Findings Routes
|
||||
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
||||
// Notes are stored separately so they survive cache refreshes.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const FINDINGS_FILTERS = [
|
||||
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
exclusive: false,
|
||||
operator: 'RANGE',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: '8.5,9.9',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'Open',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
||||
const CLOSED_COUNT_FILTERS = [
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
exclusive: false,
|
||||
operator: 'RANGE',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: '8.5,9.9',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'Closed',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': Buffer.byteLength(bodyStr)
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 20000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table init
|
||||
// ---------------------------------------------------------------------------
|
||||
function initTables(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
open_count INTEGER DEFAULT 0,
|
||||
closed_count INTEGER DEFAULT 0,
|
||||
synced_at DATETIME
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
// Idempotent column additions — errors mean the column already exists, which is fine
|
||||
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||
VALUES (1, 0, 0)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(finding_id, field)
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||
ON ivanti_finding_overrides(finding_id)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractFinding(f) {
|
||||
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
||||
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
||||
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
||||
|
||||
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
||||
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
||||
|
||||
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||
|
||||
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
||||
// system workflows and not actionable for our purposes.
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []),
|
||||
...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []),
|
||||
...(wfDist.expiredWorkflows || []),
|
||||
...(wfDist.approvedWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
|
||||
// Priority: actionable > requested > reworked > rejected > expired > approved
|
||||
const fpEntry = fpBuckets[0] || null;
|
||||
|
||||
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
||||
const generatedNames = f.workflowGeneratedNames || [];
|
||||
const fpFromNames = !fpEntry
|
||||
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||
: null;
|
||||
|
||||
const workflow = fpEntry ? {
|
||||
id: fpEntry.generatedId || '',
|
||||
state: fpEntry.state || '',
|
||||
type: 'FP',
|
||||
} : fpFromNames ? {
|
||||
id: fpFromNames,
|
||||
state: '',
|
||||
type: 'FP',
|
||||
} : null;
|
||||
|
||||
return {
|
||||
id: String(f.id),
|
||||
title: f.title || '',
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
dns: f.dns || f.host?.fqdn || '',
|
||||
status: f.status || '',
|
||||
slaStatus: f.slaStatus || '',
|
||||
dueDate,
|
||||
lastFoundOn: f.lastFoundOn || '',
|
||||
buOwnership,
|
||||
cves,
|
||||
workflow
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
try {
|
||||
const body = {
|
||||
filters: CLOSED_COUNT_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page: 0,
|
||||
size: 1
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
// RiskSense returns total in page.totalElements or page.total
|
||||
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||
// Still update open count so it stays in sync; leave closed_count as-is
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[openCount]
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract FP workflow id+state from a raw (un-extracted) finding
|
||||
// Returns { id, state } or null if no FP# workflow present.
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractFPWorkflow(f) {
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []),
|
||||
...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []),
|
||||
...(wfDist.expiredWorkflows || []),
|
||||
...(wfDist.approvedWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
const fpEntry = fpBuckets[0] || null;
|
||||
if (!fpEntry) return null;
|
||||
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync FP stats across ALL findings (open + closed).
|
||||
//
|
||||
// Produces two separate counts:
|
||||
// findingCounts — number of *findings* per FP workflow state
|
||||
// idCounts — number of *unique FP# ticket IDs* per state
|
||||
// (one FP# can cover many findings; this chart counts tickets)
|
||||
//
|
||||
// Open findings come from the already-extracted allFindings array.
|
||||
// Closed findings are swept page-by-page to catch Approved FPs.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
||||
const findingCounts = {}; // state → # findings
|
||||
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
||||
|
||||
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
||||
openFindings.forEach(f => {
|
||||
if (!f.workflow) return;
|
||||
const state = f.workflow.state || 'Unknown';
|
||||
const id = f.workflow.id || '';
|
||||
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
||||
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
||||
});
|
||||
|
||||
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const body = {
|
||||
filters: CLOSED_COUNT_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
||||
break;
|
||||
}
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
findings.forEach(f => {
|
||||
const wf = extractFPWorkflow(f);
|
||||
if (!wf) return;
|
||||
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
||||
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
||||
});
|
||||
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
||||
// Fall through — store whatever we have from open findings
|
||||
}
|
||||
|
||||
// Aggregate unique FP# IDs by state
|
||||
const idCounts = {};
|
||||
Object.values(fpIdMap).forEach(state => {
|
||||
idCounts[state] = (idCounts[state] || 0) + 1;
|
||||
});
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
||||
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
||||
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
||||
|
||||
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
||||
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncFindings(db) {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||
console.warn('[Ivanti Findings]', errMsg);
|
||||
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Ivanti Findings] Starting sync...');
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
let allFindings = [];
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const body = {
|
||||
filters: FINDINGS_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
|
||||
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
||||
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
||||
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
||||
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
allFindings = allFindings.concat(findings.map(extractFinding));
|
||||
|
||||
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||
[allFindings.length, JSON.stringify(allFindings)]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
function scheduleSync(db) {
|
||||
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
||||
if (err || !row || !row.synced_at) {
|
||||
syncFindings(db);
|
||||
} else {
|
||||
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSince >= 24) {
|
||||
syncFindings(db);
|
||||
} else {
|
||||
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
function readState(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||
let findings = [];
|
||||
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readNotes(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
||||
resolve(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readCounts(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
resolve({
|
||||
open: row?.open_count ?? 0,
|
||||
closed: row?.closed_count ?? 0,
|
||||
synced_at: row?.synced_at ?? null,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
||||
function readOverrides(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => {
|
||||
if (!map[r.finding_id]) map[r.finding_id] = {};
|
||||
map[r.finding_id][r.field] = r.value;
|
||||
});
|
||||
resolve(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readStateWithNotes(db) {
|
||||
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
||||
state.findings = state.findings.map((f) => ({
|
||||
...f,
|
||||
note: notes[f.id] || '',
|
||||
overrides: overrides[f.id] || {},
|
||||
}));
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
function createIvantiFindingsRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
initTables(db)
|
||||
.then(() => scheduleSync(db))
|
||||
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// GET / — cached findings with notes merged in
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading findings' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger immediate sync, return fresh state
|
||||
router.post('/sync', async (req, res) => {
|
||||
await syncFindings(db);
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /counts — open vs closed totals for pie chart
|
||||
router.get('/counts', async (req, res) => {
|
||||
try {
|
||||
res.json(await readCounts(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading counts' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||
router.get('/fp-workflow-counts', async (req, res) => {
|
||||
try {
|
||||
const row = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
||||
(err, row) => { if (err) reject(err); else resolve(row); }
|
||||
);
|
||||
});
|
||||
let findingCounts = {};
|
||||
let idCounts = {};
|
||||
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
||||
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
||||
res.json({
|
||||
findingCounts,
|
||||
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
||||
idCounts,
|
||||
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const { field, value } = req.body;
|
||||
|
||||
if (!OVERRIDE_ALLOWED.includes(field)) {
|
||||
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
||||
}
|
||||
|
||||
const val = String(value ?? '').trim();
|
||||
|
||||
if (val === '') {
|
||||
// Empty value = clear the override (revert to Ivanti)
|
||||
db.run(
|
||||
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
||||
[findingId, field],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
||||
res.json({ finding_id: findingId, field, value: null });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
db.run(
|
||||
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
||||
[findingId, field, val],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
||||
res.json({ finding_id: findingId, field, value: val });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||
router.put('/:findingId/note', (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const note = String(req.body.note || '').slice(0, 255);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
||||
[findingId, note],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
||||
res.json({ finding_id: findingId, note });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiFindingsRouter;
|
||||
214
backend/routes/ivantiTodoQueue.js
Normal file
214
backend/routes/ivantiTodoQueue.js
Normal file
@@ -0,0 +1,214 @@
|
||||
// routes/ivantiTodoQueue.js
|
||||
const express = require('express');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
||||
const VALID_STATUSES = ['pending', 'complete'];
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/ivanti/todo-queue
|
||||
// Fetch current user's queue items, ordered by vendor then created_at
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
db.all(
|
||||
`SELECT * FROM ivanti_todo_queue
|
||||
WHERE user_id = ?
|
||||
ORDER BY vendor ASC, created_at ASC`,
|
||||
[req.user.id],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching todo queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
// Parse cves_json back to array for each row
|
||||
const parsed = rows.map((r) => ({
|
||||
...r,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
res.json(parsed);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/ivanti/todo-queue
|
||||
// Add a finding to the queue
|
||||
router.post('/', requireAuth(db), (req, res) => {
|
||||
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
|
||||
|
||||
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'finding_id is required.' });
|
||||
}
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
|
||||
}
|
||||
// Vendor is required for FP and Archer, optional for CARD
|
||||
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
||||
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
||||
const title = finding_title && typeof finding_title === 'string'
|
||||
? finding_title.slice(0, 500)
|
||||
: null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error adding to queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||
[this.lastID],
|
||||
(err2, row) => {
|
||||
if (err2 || !row) {
|
||||
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||
}
|
||||
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// PUT /api/ivanti/todo-queue/:id
|
||||
// Update vendor, workflow_type, or status — scoped to current user
|
||||
router.put('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { vendor, workflow_type, status } = req.body;
|
||||
|
||||
if (vendor !== undefined && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
||||
}
|
||||
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||
}
|
||||
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.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: 'Queue item not found.' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (vendor !== undefined) {
|
||||
updates.push('vendor = ?');
|
||||
params.push(vendor.trim());
|
||||
}
|
||||
if (workflow_type !== undefined) {
|
||||
updates.push('workflow_type = ?');
|
||||
params.push(workflow_type);
|
||||
}
|
||||
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, req.user.id);
|
||||
|
||||
db.run(
|
||||
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||
params,
|
||||
function (err2) {
|
||||
if (err2) {
|
||||
console.error(err2);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||
[id],
|
||||
(err3, row) => {
|
||||
if (err3 || !row) {
|
||||
return res.json({ message: 'Queue item updated.' });
|
||||
}
|
||||
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/completed
|
||||
// Bulk-delete all completed items for the current user
|
||||
// IMPORTANT: This route must be registered BEFORE DELETE /:id
|
||||
router.delete('/completed', requireAuth(db), (req, res) => {
|
||||
db.run(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
||||
[req.user.id],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error clearing completed queue items:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ message: 'Completed items cleared.', deleted: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/:id
|
||||
// Delete a single item — scoped to current user
|
||||
router.delete('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
function (err2) {
|
||||
if (err2) {
|
||||
console.error(err2);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ message: 'Queue item deleted.' });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiTodoQueueRouter;
|
||||
274
backend/routes/ivantiWorkflows.js
Normal file
274
backend/routes/ivantiWorkflows.js
Normal file
@@ -0,0 +1,274 @@
|
||||
// Ivanti / RiskSense Workflow Routes
|
||||
// Data is cached in SQLite and refreshed on a daily schedule or on-demand.
|
||||
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
|
||||
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — uses Node's https module directly so we can toggle
|
||||
// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': Buffer.byteLength(bodyStr)
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 15000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure the sync state table exists (idempotent — safe to call on every start)
|
||||
// ---------------------------------------------------------------------------
|
||||
function initTable(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
workflows_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync — calls Ivanti API, stores result in SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncWorkflows(db) {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||
console.warn('[Ivanti]', errMsg);
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[errMsg], resolve
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Ivanti] Syncing workflows...');
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||
const body = {
|
||||
filters: [
|
||||
{
|
||||
field: 'created_by_last_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: lastName,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'created_by_first_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: firstName,
|
||||
caseSensitive: false
|
||||
}
|
||||
],
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'created', direction: 'DESC' }],
|
||||
page: 0,
|
||||
size: 50
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
|
||||
if (result.status === 401) {
|
||||
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||
}
|
||||
if (result.status === 419) {
|
||||
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||
}
|
||||
if (result.status === 429) {
|
||||
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||
}
|
||||
if (result.status !== 200) {
|
||||
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
|
||||
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
|
||||
let total = 0;
|
||||
let workflows = [];
|
||||
|
||||
if (data.page && typeof data.page.totalElements === 'number') {
|
||||
total = data.page.totalElements;
|
||||
workflows = data._embedded?.workflowBatches
|
||||
|| data._embedded?.workflowBatch
|
||||
|| [];
|
||||
} else if (typeof data.total === 'number') {
|
||||
total = data.total;
|
||||
workflows = data.data || data.content || data.results || [];
|
||||
} else if (typeof data.totalElements === 'number') {
|
||||
total = data.totalElements;
|
||||
workflows = data.content || data.data || [];
|
||||
} else if (Array.isArray(data)) {
|
||||
workflows = data;
|
||||
total = data.length;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state
|
||||
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL
|
||||
WHERE id=1`,
|
||||
[total, JSON.stringify(workflows)],
|
||||
(err) => { if (err) reject(err); else resolve(); }
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti] Sync failed:', msg);
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[msg], resolve
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
||||
// ---------------------------------------------------------------------------
|
||||
function scheduleSync(db) {
|
||||
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => {
|
||||
if (err || !row || !row.synced_at) {
|
||||
syncWorkflows(db);
|
||||
} else {
|
||||
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSince >= 24) {
|
||||
syncWorkflows(db);
|
||||
} else {
|
||||
const hoursUntil = (24 - hoursSince).toFixed(1);
|
||||
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper — read current state from DB and return as JSON-ready object
|
||||
// ---------------------------------------------------------------------------
|
||||
function readState(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||
|
||||
let workflows = [];
|
||||
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
|
||||
resolve({
|
||||
total: row.total || 0,
|
||||
workflows,
|
||||
synced_at: row.synced_at,
|
||||
sync_status: row.sync_status,
|
||||
error_message: row.error_message
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// Init table and kick off scheduler (fire-and-forget on startup)
|
||||
initTable(db)
|
||||
.then(() => scheduleSync(db))
|
||||
.catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// GET / — return cached data (fast, no external call)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading sync state' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||
router.post('/sync', async (req, res) => {
|
||||
await syncWorkflows(db);
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiWorkflowsRouter;
|
||||
352
backend/routes/knowledgeBase.js
Normal file
352
backend/routes/knowledgeBase.js
Normal file
@@ -0,0 +1,352 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
function createKnowledgeBaseRouter(db, upload) {
|
||||
const router = express.Router();
|
||||
|
||||
// Helper to sanitize filename
|
||||
function sanitizePathSegment(segment) {
|
||||
if (!segment || typeof segment !== 'string') return '';
|
||||
return segment
|
||||
.replace(/\0/g, '')
|
||||
.replace(/\.\./g, '')
|
||||
.replace(/[\/\\]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Helper to generate slug from title
|
||||
function generateSlug(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 200);
|
||||
}
|
||||
|
||||
// Helper to validate file type
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.pdf', '.md', '.txt', '.doc', '.docx',
|
||||
'.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.html', '.htm', '.json', '.yaml', '.yml',
|
||||
'.png', '.jpg', '.jpeg', '.gif'
|
||||
]);
|
||||
|
||||
function isValidFileType(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
// POST /api/knowledge-base/upload - Upload new document
|
||||
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 { title, description, category } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !title.trim()) {
|
||||
console.error('[KB Upload] Error: Title is missing');
|
||||
if (uploadedFile) fs.unlinkSync(uploadedFile.path);
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
if (!uploadedFile) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!isValidFileType(uploadedFile.originalname)) {
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
return res.status(400).json({ error: 'File type not allowed' });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||
const slug = generateSlug(title);
|
||||
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(kbDir)) {
|
||||
fs.mkdirSync(kbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${timestamp}_${sanitizedName}`;
|
||||
const filePath = path.join(kbDir, filename);
|
||||
|
||||
try {
|
||||
// Move uploaded file to permanent location
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
|
||||
// Check if slug already exists
|
||||
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||
if (err) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.error('Error checking slug:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
|
||||
// If slug exists, append timestamp to make it unique
|
||||
const finalSlug = row ? `${slug}-${timestamp}` : slug;
|
||||
|
||||
// Insert new knowledge base entry
|
||||
const insertSql = `
|
||||
INSERT INTO knowledge_base (
|
||||
title, slug, description, category, file_path, file_name,
|
||||
file_type, file_size, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(
|
||||
insertSql,
|
||||
[
|
||||
title.trim(),
|
||||
finalSlug,
|
||||
description || null,
|
||||
category || 'General',
|
||||
filePath,
|
||||
sanitizedName,
|
||||
uploadedFile.mimetype,
|
||||
uploadedFile.size,
|
||||
req.user.id
|
||||
],
|
||||
function (err) {
|
||||
if (err) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.error('Error inserting knowledge base entry:', err);
|
||||
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'CREATE_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
this.lastID,
|
||||
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: this.lastID,
|
||||
title: title.trim(),
|
||||
slug: finalSlug,
|
||||
category: category || 'General'
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up file on error
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
console.error('Error uploading knowledge base document:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base - List all articles
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const sql = `
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
ORDER BY kb.created_at DESC
|
||||
`;
|
||||
|
||||
db.all(sql, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching knowledge base articles:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch articles' });
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id - Get single article details
|
||||
router.get('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
WHERE kb.id = ?
|
||||
`;
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching article:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/content - Get document content for display
|
||||
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(row.file_path)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'VIEW_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ filename: row.file_name }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
// Determine content type for inline display
|
||||
let contentType = row.file_type || 'application/octet-stream';
|
||||
|
||||
// For markdown files, send as plain text so frontend can parse it
|
||||
if (row.file_name.endsWith('.md')) {
|
||||
contentType = 'text/plain; charset=utf-8';
|
||||
} else if (row.file_name.endsWith('.txt')) {
|
||||
contentType = 'text/plain; charset=utf-8';
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
// Use inline instead of attachment to allow browser to display
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/download - Download document
|
||||
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(row.file_path)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DOWNLOAD_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ filename: row.file_name }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/knowledge-base/:id - Delete article
|
||||
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching article for deletion:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting article:', err);
|
||||
return res.status(500).json({ error: 'Failed to delete article' });
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if (fs.existsSync(row.file_path)) {
|
||||
fs.unlinkSync(row.file_path);
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DELETE_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ title: row.title }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createKnowledgeBaseRouter;
|
||||
182
backend/scripts/import_notes_from_csv.py
Normal file
182
backend/scripts/import_notes_from_csv.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_notes_from_csv.py
|
||||
------------------------
|
||||
Mass-import finding notes from a CSV file into the CVE dashboard database.
|
||||
|
||||
CSV format (header row required, column names are case-insensitive):
|
||||
ID,NOTES
|
||||
12345,EXC-5754
|
||||
67890,EXC-6001 - pending review
|
||||
|
||||
Usage:
|
||||
python3 import_notes_from_csv.py <csv_file> [--db <db_path>] [--dry-run]
|
||||
|
||||
Options:
|
||||
--db <path> Path to cve_database.db (default: ../cve_database.db)
|
||||
--dry-run Print what would change without touching the database
|
||||
"""
|
||||
|
||||
import csv
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
|
||||
NOTE_MAX_LEN = 255
|
||||
|
||||
DEFAULT_DB = os.path.join(os.path.dirname(__file__), '..', 'cve_database.db')
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description='Import finding notes from CSV into the dashboard DB.')
|
||||
p.add_argument('csv_file', help='Path to the CSV file (must have ID and NOTES columns)')
|
||||
p.add_argument('--db', default=DEFAULT_DB, help=f'Path to SQLite database (default: {DEFAULT_DB})')
|
||||
p.add_argument('--dry-run', action='store_true', help='Preview changes without writing to DB')
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def load_csv(path):
|
||||
"""Read CSV and return list of (finding_id, note) tuples."""
|
||||
rows = []
|
||||
with open(path, newline='', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Normalise header names to uppercase for case-insensitive matching
|
||||
if reader.fieldnames is None:
|
||||
print('ERROR: CSV file is empty or has no header row.')
|
||||
sys.exit(1)
|
||||
|
||||
normalised = {k.strip().upper(): k for k in reader.fieldnames}
|
||||
if 'ID' not in normalised or 'NOTES' not in normalised:
|
||||
print(f'ERROR: CSV must have "ID" and "NOTES" columns.')
|
||||
print(f' Found columns: {list(reader.fieldnames)}')
|
||||
sys.exit(1)
|
||||
|
||||
id_col = normalised['ID']
|
||||
notes_col = normalised['NOTES']
|
||||
|
||||
for i, row in enumerate(reader, start=2): # start=2 because row 1 is the header
|
||||
finding_id = row[id_col].strip()
|
||||
note = row[notes_col].strip()
|
||||
|
||||
if not finding_id:
|
||||
print(f' WARNING row {i}: empty ID — skipping')
|
||||
continue
|
||||
|
||||
if len(note) > NOTE_MAX_LEN:
|
||||
print(f' WARNING row {i} ({finding_id}): note is {len(note)} chars, '
|
||||
f'truncating to {NOTE_MAX_LEN}')
|
||||
note = note[:NOTE_MAX_LEN]
|
||||
|
||||
rows.append((finding_id, note))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def run(args):
|
||||
csv_path = os.path.abspath(args.csv_file)
|
||||
db_path = os.path.abspath(args.db)
|
||||
|
||||
# ------------------------------------------------------------------ checks
|
||||
if not os.path.exists(csv_path):
|
||||
print(f'ERROR: CSV file not found: {csv_path}')
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f'ERROR: Database not found: {db_path}')
|
||||
sys.exit(1)
|
||||
|
||||
print(f'CSV : {csv_path}')
|
||||
print(f'DB : {db_path}')
|
||||
if args.dry_run:
|
||||
print('MODE: DRY RUN — no changes will be written\n')
|
||||
else:
|
||||
print()
|
||||
|
||||
# ----------------------------------------------------------------- load CSV
|
||||
rows = load_csv(csv_path)
|
||||
if not rows:
|
||||
print('No valid rows found in CSV.')
|
||||
sys.exit(0)
|
||||
|
||||
print(f'Loaded {len(rows)} row(s) from CSV.\n')
|
||||
|
||||
# ---------------------------------------------------------------- open DB
|
||||
con = sqlite3.connect(db_path)
|
||||
con.row_factory = sqlite3.Row
|
||||
cur = con.cursor()
|
||||
|
||||
# Fetch all known finding IDs — only IDs present here will be processed
|
||||
import json
|
||||
cur.execute('SELECT findings_json FROM ivanti_findings_cache WHERE id = 1')
|
||||
cache_row = cur.fetchone()
|
||||
known_ids = set()
|
||||
if cache_row and cache_row['findings_json']:
|
||||
try:
|
||||
known_ids = {str(f['id']) for f in json.loads(cache_row['findings_json'])}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not known_ids:
|
||||
print('ERROR: No findings found in the database cache.')
|
||||
print(' Run a Sync from the dashboard first, then re-run this script.')
|
||||
con.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f'{len(known_ids)} active finding(s) in cache.\n')
|
||||
|
||||
# ----------------------------------------------------------------- process
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
|
||||
for finding_id, note in rows:
|
||||
str_id = str(finding_id)
|
||||
|
||||
if str_id not in known_ids:
|
||||
print(f' SKIP {str_id} — not in active findings (resolved or never synced)')
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Check if a note already exists
|
||||
cur.execute('SELECT note FROM ivanti_finding_notes WHERE finding_id = ?', (str_id,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing['note'] == note:
|
||||
print(f' SKIP {str_id} — note unchanged')
|
||||
skipped += 1
|
||||
continue
|
||||
action = 'UPDATE'
|
||||
updated += 1
|
||||
else:
|
||||
action = 'INSERT'
|
||||
inserted += 1
|
||||
|
||||
print(f' {action:6s} {str_id} → {note[:80]}{"…" if len(note) > 80 else ""}')
|
||||
|
||||
if not args.dry_run:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id) DO UPDATE
|
||||
SET note = excluded.note, updated_at = datetime('now')
|
||||
""",
|
||||
(str_id, note)
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------- summary
|
||||
print()
|
||||
if args.dry_run:
|
||||
print(f'DRY RUN complete — would insert {inserted}, update {updated}, skip {skipped}.')
|
||||
else:
|
||||
con.commit()
|
||||
print(f'Done — inserted {inserted}, updated {updated}, skipped {skipped} (unchanged).')
|
||||
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(parse_args())
|
||||
2
backend/scripts/requirements.txt
Normal file
2
backend/scripts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.0.0
|
||||
@@ -18,6 +18,11 @@ const createUsersRouter = require('./routes/users');
|
||||
const createAuditLogRouter = require('./routes/auditLog');
|
||||
const logAudit = require('./helpers/auditLog');
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -27,25 +32,127 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',')
|
||||
: ['http://localhost:3000'];
|
||||
|
||||
// ========== SECURITY HELPERS ==========
|
||||
|
||||
// Allowed file extensions for document uploads (documents only, no executables)
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
||||
'.txt', '.md', '.csv', '.log', '.msg', '.eml',
|
||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.odt', '.ods', '.odp',
|
||||
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||
'.zip', '.gz', '.tar', '.7z'
|
||||
]);
|
||||
|
||||
// Allowed MIME type prefixes
|
||||
const ALLOWED_MIME_PREFIXES = [
|
||||
'image/', 'text/', 'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats',
|
||||
'application/vnd.ms-', 'application/vnd.oasis.opendocument',
|
||||
'application/rtf', 'application/json', 'application/xml',
|
||||
'application/vnd.ms-outlook', 'message/rfc822',
|
||||
'application/zip', 'application/gzip', 'application/x-7z',
|
||||
'application/x-tar', 'application/octet-stream'
|
||||
];
|
||||
|
||||
// Sanitize a single path segment (cveId, vendor, filename) to prevent traversal
|
||||
function sanitizePathSegment(segment) {
|
||||
if (!segment || typeof segment !== 'string') return '';
|
||||
// Remove path separators, null bytes, and .. sequences
|
||||
return segment
|
||||
.replace(/\0/g, '')
|
||||
.replace(/\.\./g, '')
|
||||
.replace(/[\/\\]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Validate that a resolved path is within the uploads directory
|
||||
function isPathWithinUploads(targetPath) {
|
||||
const uploadsRoot = path.resolve('uploads');
|
||||
const resolved = path.resolve(targetPath);
|
||||
return resolved.startsWith(uploadsRoot + path.sep) || resolved === uploadsRoot;
|
||||
}
|
||||
|
||||
// Validate CVE ID format
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
function isValidCveId(cveId) {
|
||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||
}
|
||||
|
||||
// Allowed enum values
|
||||
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) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
// Log all incoming requests
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: CORS_ORIGINS,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
// Only parse JSON for requests with application/json content type
|
||||
app.use(express.json({
|
||||
limit: '1mb',
|
||||
type: 'application/json'
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
index: false
|
||||
}));
|
||||
|
||||
// Database connection
|
||||
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||
if (err) console.error('Database connection error:', err);
|
||||
else console.log('Connected to CVE database');
|
||||
if (err) {
|
||||
console.error('Database connection error:', err);
|
||||
return;
|
||||
}
|
||||
console.log('Connected to CVE database');
|
||||
|
||||
// Ensure ivanti_todo_queue table exists (idempotent migration)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
ip_address TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err2) => {
|
||||
if (err2) console.error('Failed to create ivanti_todo_queue table:', err2);
|
||||
else db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err3) => { if (err3) console.error('Failed to create todo_queue index:', err3); }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Auth routes (public)
|
||||
@@ -71,15 +178,46 @@ const storage = multer.diskStorage({
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
cb(null, `${timestamp}-${file.originalname}`);
|
||||
// Sanitize original filename - strip path components and dangerous chars
|
||||
const safeName = sanitizePathSegment(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${timestamp}-${safeName}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter - reject executables and non-allowed types
|
||||
function fileFilter(req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||
return cb(new Error(`File type '${ext}' is not allowed. Allowed types: ${[...ALLOWED_EXTENSIONS].join(', ')}`));
|
||||
}
|
||||
const mimeAllowed = ALLOWED_MIME_PREFIXES.some(prefix => file.mimetype.startsWith(prefix));
|
||||
if (!mimeAllowed) {
|
||||
return cb(new Error(`MIME type '${file.mimetype}' is not allowed.`));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: fileFilter,
|
||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||
|
||||
// Ivanti / RiskSense host findings routes (all authenticated users)
|
||||
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
||||
|
||||
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
@@ -87,15 +225,9 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
||||
const { search, vendor, severity, status } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT c.*,
|
||||
COUNT(d.id) as document_count,
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN d.type = 'advisory' THEN 1 END) > 0
|
||||
THEN 'Complete'
|
||||
ELSE 'Incomplete'
|
||||
END as doc_status
|
||||
SELECT c.*, COUNT(d.id) as document_count
|
||||
FROM cves c
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
@@ -123,7 +255,7 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching CVEs:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -132,7 +264,7 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
||||
// Get distinct CVE IDs for NVD sync (authenticated users)
|
||||
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
||||
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
res.json(rows.map(r => r.cve_id));
|
||||
});
|
||||
});
|
||||
@@ -144,7 +276,6 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||
const query = `
|
||||
SELECT c.*,
|
||||
COUNT(d.id) as total_documents,
|
||||
COUNT(CASE WHEN d.type = 'advisory' THEN 1 END) as has_advisory,
|
||||
COUNT(CASE WHEN d.type = 'email' THEN 1 END) as has_email,
|
||||
COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot
|
||||
FROM cves c
|
||||
@@ -155,7 +286,7 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||
|
||||
db.all(query, [cveId], (err, rows) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!rows || rows.length === 0) {
|
||||
return res.json({
|
||||
@@ -172,14 +303,12 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
total_documents: row.total_documents,
|
||||
compliance: {
|
||||
advisory: row.has_advisory > 0,
|
||||
doc_types: {
|
||||
email: row.has_email > 0,
|
||||
screenshot: row.has_screenshot > 0
|
||||
}
|
||||
})),
|
||||
addressed: true,
|
||||
has_required_docs: rows.some(row => row.has_advisory > 0)
|
||||
addressed: true
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -197,40 +326,59 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
||||
|
||||
db.all(query, [cveId], (err, rows) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Compliance export — reads from cve_document_status view
|
||||
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||
db.all('SELECT * FROM cve_document_status ORDER BY cve_id, vendor', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching compliance data:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
||||
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
console.log('=== ADD CVE REQUEST ===');
|
||||
console.log('Body:', req.body);
|
||||
console.log('=======================');
|
||||
|
||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||
|
||||
// Input validation
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Vendor is required and must be under 200 characters.' });
|
||||
}
|
||||
if (!severity || !VALID_SEVERITIES.includes(severity)) {
|
||||
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
||||
}
|
||||
if (!description || typeof description !== 'string' || description.length > 10000) {
|
||||
return res.status(400).json({ error: 'Description is required and must be under 10000 characters.' });
|
||||
}
|
||||
if (!published_date || !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
||||
return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' });
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
console.log('Query:', query);
|
||||
console.log('Values:', [cve_id, vendor, severity, description, published_date]);
|
||||
|
||||
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
||||
if (err) {
|
||||
console.error('DATABASE ERROR:', err); // Make sure this is here
|
||||
// ... rest of error handling
|
||||
// Check if it's a duplicate CVE_ID + Vendor combination
|
||||
console.error('DATABASE ERROR:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(409).json({
|
||||
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
||||
});
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
return res.status(500).json({ error: 'Failed to create CVE entry.' });
|
||||
}
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
@@ -255,11 +403,15 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
||||
const { cveId } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
||||
|
||||
db.run(query, [status, cveId], function(err) {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
@@ -314,7 +466,8 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
|
||||
values,
|
||||
function(err) {
|
||||
if (err) {
|
||||
errors.push({ cve_id: entry.cve_id, error: err.message });
|
||||
console.error('NVD sync update error:', err);
|
||||
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
|
||||
} else {
|
||||
updated += this.changes;
|
||||
}
|
||||
@@ -341,6 +494,249 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
|
||||
}
|
||||
});
|
||||
|
||||
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
||||
|
||||
// Edit single CVE entry (editor or admin)
|
||||
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
||||
|
||||
// Input validation for provided fields
|
||||
if (cve_id !== undefined && !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||
}
|
||||
if (vendor !== undefined && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Vendor must be under 200 characters.' });
|
||||
}
|
||||
if (severity !== undefined && !VALID_SEVERITIES.includes(severity)) {
|
||||
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
||||
}
|
||||
if (description !== undefined && (typeof description !== 'string' || description.length > 10000)) {
|
||||
return res.status(400).json({ error: 'Description must be under 10000 characters.' });
|
||||
}
|
||||
if (published_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
||||
return res.status(400).json({ error: 'Published date must be in YYYY-MM-DD format.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Fetch existing row first
|
||||
db.get('SELECT * FROM cves 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: 'CVE entry not found' });
|
||||
|
||||
const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status };
|
||||
|
||||
const newCveId = cve_id !== undefined ? cve_id : existing.cve_id;
|
||||
const newVendor = vendor !== undefined ? vendor : existing.vendor;
|
||||
const cveIdChanged = newCveId !== existing.cve_id;
|
||||
const vendorChanged = newVendor !== existing.vendor;
|
||||
|
||||
const doUpdate = () => {
|
||||
// Build dynamic SET clause
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (cve_id !== undefined) { fields.push('cve_id = ?'); values.push(cve_id); }
|
||||
if (vendor !== undefined) { fields.push('vendor = ?'); values.push(vendor); }
|
||||
if (severity !== undefined) { fields.push('severity = ?'); values.push(severity); }
|
||||
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
|
||||
if (published_date !== undefined) { fields.push('published_date = ?'); values.push(published_date); }
|
||||
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.run(`UPDATE cves SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
const after = {
|
||||
cve_id: newCveId, vendor: newVendor,
|
||||
severity: severity !== undefined ? severity : existing.severity,
|
||||
description: description !== undefined ? description : existing.description,
|
||||
published_date: published_date !== undefined ? published_date : existing.published_date,
|
||||
status: status !== undefined ? status : existing.status
|
||||
};
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'cve_edit',
|
||||
entityType: 'cve',
|
||||
entityId: newCveId,
|
||||
details: { before, after },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'CVE updated successfully', changes: this.changes });
|
||||
});
|
||||
};
|
||||
|
||||
if (cveIdChanged || vendorChanged) {
|
||||
// Check UNIQUE constraint
|
||||
db.get('SELECT id FROM cves WHERE cve_id = ? AND vendor = ? AND id != ?', [newCveId, newVendor, id], (checkErr, conflict) => {
|
||||
if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
|
||||
|
||||
// Rename document directory (with path traversal prevention)
|
||||
const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor));
|
||||
const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor));
|
||||
|
||||
if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' });
|
||||
}
|
||||
|
||||
if (fs.existsSync(oldDir)) {
|
||||
const newParent = path.join('uploads', newCveId);
|
||||
if (!fs.existsSync(newParent)) {
|
||||
fs.mkdirSync(newParent, { recursive: true });
|
||||
}
|
||||
fs.renameSync(oldDir, newDir);
|
||||
|
||||
// Clean up old cve_id directory if empty
|
||||
const oldParent = path.join('uploads', existing.cve_id);
|
||||
if (fs.existsSync(oldParent)) {
|
||||
const remaining = fs.readdirSync(oldParent);
|
||||
if (remaining.length === 0) fs.rmdirSync(oldParent);
|
||||
}
|
||||
}
|
||||
|
||||
// Update documents table - file paths
|
||||
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [existing.cve_id, existing.vendor], (docErr, docs) => {
|
||||
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor);
|
||||
const newPrefix = path.join('uploads', newCveId, newVendor);
|
||||
|
||||
let docUpdated = 0;
|
||||
const totalDocs = docs.length;
|
||||
|
||||
const finishDocUpdate = () => {
|
||||
if (docUpdated >= totalDocs) doUpdate();
|
||||
};
|
||||
|
||||
if (totalDocs === 0) {
|
||||
doUpdate();
|
||||
} else {
|
||||
docs.forEach((doc) => {
|
||||
const newFilePath = doc.file_path.replace(oldPrefix, newPrefix);
|
||||
db.run('UPDATE documents SET cve_id = ?, vendor = ?, file_path = ? WHERE id = ?',
|
||||
[newCveId, newVendor, newFilePath, doc.id],
|
||||
(docUpdateErr) => {
|
||||
if (docUpdateErr) console.error('Error updating document:', docUpdateErr);
|
||||
docUpdated++;
|
||||
finishDocUpdate();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
doUpdate();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
||||
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
|
||||
// Get all rows for this CVE ID to know what we're deleting
|
||||
db.all('SELECT * FROM cves WHERE cve_id = ?', [cveId], (err, rows) => {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
||||
|
||||
// Delete all documents from DB
|
||||
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
||||
if (docErr) console.error('Error deleting documents:', docErr);
|
||||
|
||||
// Delete all CVE rows
|
||||
db.run('DELETE FROM cves WHERE cve_id = ?', [cveId], function(cveErr) {
|
||||
if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
// Remove upload directory (with path traversal prevention)
|
||||
const safeCveId = sanitizePathSegment(cveId);
|
||||
const cveDir = path.join('uploads', safeCveId);
|
||||
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
|
||||
fs.rmSync(cveDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'cve_delete',
|
||||
entityType: 'cve',
|
||||
entityId: cveId,
|
||||
details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: this.changes });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Delete single CVE vendor entry (editor or admin)
|
||||
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
||||
|
||||
// Delete associated documents from DB
|
||||
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
||||
if (docErr) console.error('Error fetching documents:', docErr);
|
||||
|
||||
// Delete document files from disk (with path traversal prevention)
|
||||
if (docs && docs.length > 0) {
|
||||
docs.forEach(doc => {
|
||||
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
|
||||
fs.unlinkSync(doc.file_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete documents from DB
|
||||
db.run('DELETE FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (delDocErr) => {
|
||||
if (delDocErr) console.error('Error deleting documents from DB:', delDocErr);
|
||||
|
||||
// Delete CVE row
|
||||
db.run('DELETE FROM cves WHERE id = ?', [id], function(delErr) {
|
||||
if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||
|
||||
// Clean up directories (with path traversal prevention)
|
||||
const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor));
|
||||
if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) {
|
||||
fs.rmSync(safeVendorDir, { recursive: true, force: true });
|
||||
}
|
||||
const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id));
|
||||
if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) {
|
||||
const remaining = fs.readdirSync(safeCveDir);
|
||||
if (remaining.length === 0) fs.rmdirSync(safeCveDir);
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'cve_delete',
|
||||
entityType: 'cve',
|
||||
entityId: cve.cve_id,
|
||||
details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DOCUMENT ENDPOINTS ==========
|
||||
|
||||
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
||||
@@ -360,7 +756,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
@@ -370,15 +766,16 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
||||
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('MULTER ERROR:', err);
|
||||
return res.status(500).json({ error: 'File upload failed: ' + err.message });
|
||||
console.error('Upload error:', err.message);
|
||||
// Show file validation errors to the user; hide other internal errors
|
||||
if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File exceeds the 10MB size limit.' });
|
||||
}
|
||||
return res.status(500).json({ error: 'File upload failed.' });
|
||||
}
|
||||
|
||||
console.log('=== UPLOAD REQUEST RECEIVED ===');
|
||||
console.log('CVE ID:', req.params.cveId);
|
||||
console.log('Body:', req.body);
|
||||
console.log('File:', req.file);
|
||||
console.log('================================');
|
||||
|
||||
const { cveId } = req.params;
|
||||
const { type, notes, vendor } = req.body;
|
||||
@@ -390,18 +787,41 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
||||
}
|
||||
|
||||
if (!vendor) {
|
||||
console.error('ERROR: Vendor is required');
|
||||
// Clean up temp file
|
||||
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||
return res.status(400).json({ error: 'Vendor is required' });
|
||||
}
|
||||
|
||||
// Validate document type
|
||||
if (type && !VALID_DOC_TYPES.includes(type)) {
|
||||
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||
return res.status(400).json({ error: `Invalid document type. Must be one of: ${VALID_DOC_TYPES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Sanitize path segments to prevent directory traversal
|
||||
const safeCveId = sanitizePathSegment(cveId);
|
||||
const safeVendor = sanitizePathSegment(vendor);
|
||||
const safeFilename = sanitizePathSegment(file.filename);
|
||||
|
||||
if (!safeCveId || !safeVendor || !safeFilename) {
|
||||
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||
return res.status(400).json({ error: 'Invalid CVE ID, vendor, or filename.' });
|
||||
}
|
||||
|
||||
// Move file from temp to proper location
|
||||
const finalDir = path.join('uploads', cveId, vendor);
|
||||
const finalDir = path.join('uploads', safeCveId, safeVendor);
|
||||
const finalPath = path.join(finalDir, safeFilename);
|
||||
|
||||
// Verify paths stay within uploads directory
|
||||
if (!isPathWithinUploads(finalDir) || !isPathWithinUploads(finalPath)) {
|
||||
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||
return res.status(400).json({ error: 'Invalid file path.' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(finalDir)) {
|
||||
fs.mkdirSync(finalDir, { recursive: true });
|
||||
}
|
||||
|
||||
const finalPath = path.join(finalDir, file.filename);
|
||||
|
||||
// Move file from temp to final location
|
||||
fs.renameSync(file.path, finalPath);
|
||||
|
||||
@@ -423,12 +843,12 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
||||
notes
|
||||
], function(err) {
|
||||
if (err) {
|
||||
console.error('DATABASE ERROR:', err);
|
||||
console.error('Document insert error:', err);
|
||||
// If database insert fails, delete the file
|
||||
if (fs.existsSync(finalPath)) {
|
||||
fs.unlinkSync(finalPath);
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
@@ -444,7 +864,6 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
||||
message: 'Document uploaded successfully',
|
||||
file: {
|
||||
name: file.originalname,
|
||||
path: finalPath,
|
||||
size: fileSizeKB
|
||||
}
|
||||
});
|
||||
@@ -458,16 +877,17 @@ app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, re
|
||||
// First get the file path to delete the actual file
|
||||
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
if (row && fs.existsSync(row.file_path)) {
|
||||
// Only delete file if path is within uploads directory
|
||||
if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) {
|
||||
fs.unlinkSync(row.file_path);
|
||||
}
|
||||
|
||||
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
@@ -491,7 +911,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => {
|
||||
|
||||
db.all(query, [], (err, rows) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows.map(r => r.vendor));
|
||||
});
|
||||
@@ -513,12 +933,198 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
|
||||
db.get(query, [], (err, row) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 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}`);
|
||||
|
||||
120
docs/MOP-workflow-color-codes.md
Normal file
120
docs/MOP-workflow-color-codes.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
|
||||
|
||||
**Document Type:** Method of Procedure
|
||||
**Applies To:** STEAM Security Dashboard — Reporting Page
|
||||
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Background
|
||||
|
||||
### What the Reporting Page Shows
|
||||
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
|
||||
|
||||
### What the Workflow Column Shows
|
||||
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
|
||||
|
||||
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
|
||||
|
||||
### Key Rule
|
||||
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
|
||||
|
||||
---
|
||||
|
||||
## 3. Workflow Column Color Codes
|
||||
|
||||
### 🔴 Red — Act Immediately
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||
| **Rejected** | The security team reviewed the FP request and denied it. The finding is considered a real, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Amber — Action Required Soon
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
|
||||
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
|
||||
|
||||
---
|
||||
|
||||
### 🔵 Blue — In Flight, Monitor
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
|
||||
|
||||
---
|
||||
|
||||
### — (No Badge) — Untriaged
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision Flowchart
|
||||
|
||||
```
|
||||
Finding appears in Reporting page
|
||||
│
|
||||
├── Does it have a Workflow badge?
|
||||
│ │
|
||||
│ ├── NO (—)
|
||||
│ │ └── Triage → Remediate OR submit new FP request
|
||||
│ │
|
||||
│ └── YES → Check the color:
|
||||
│ │
|
||||
│ ├── 🔵 BLUE (Requested)
|
||||
│ │ └── Wait for approval. Follow up if SLA window is approaching.
|
||||
│ │
|
||||
│ ├── 🟡 AMBER (Reworked / Actionable)
|
||||
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
|
||||
│ │
|
||||
│ └── 🔴 RED
|
||||
│ │
|
||||
│ ├── Expired → Submit NEW FP request in Ivanti
|
||||
│ │
|
||||
│ └── Rejected → Remediate the vulnerability
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. How to Submit or Renew an FP Request in Ivanti
|
||||
|
||||
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
|
||||
2. Navigate to **Host Findings**
|
||||
3. Search for the Finding ID shown in the dashboard (Finding ID column)
|
||||
4. Select the finding → **Actions** → **Request False Positive**
|
||||
5. Complete the justification form:
|
||||
- Describe why the finding is not exploitable in this environment
|
||||
- Reference any compensating controls, network segmentation, or vendor guidance
|
||||
- Attach supporting evidence if available
|
||||
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
|
||||
|
||||
---
|
||||
|
||||
## 6. Quick Reference Card
|
||||
|
||||
| Badge Color | State | One-Line Action |
|
||||
|---|---|---|
|
||||
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||
| 🟡 Amber | Actionable | Review ticket in Ivanti |
|
||||
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||
| — | No badge | Triage: remediate or submit FP |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-11*
|
||||
@@ -1,70 +0,0 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
0
frontend/cve_database.db
Normal file
0
frontend/cve_database.db
Normal file
@@ -10,8 +10,10 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -25,9 +25,67 @@
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>CVE Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'mono': ['JetBrains Mono', 'monospace'],
|
||||
'sans': ['Outfit', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
'intel': {
|
||||
'darkest': '#0A0E27',
|
||||
'dark': '#131937',
|
||||
'medium': '#1E2749',
|
||||
'accent': '#00D9FF',
|
||||
'accent-dim': '#0099BB',
|
||||
'danger': '#FF3366',
|
||||
'warning': '#FFB800',
|
||||
'success': '#00FF88',
|
||||
'grid': '#1E2749',
|
||||
}
|
||||
},
|
||||
backgroundImage: {
|
||||
'grid-pattern': 'linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 217, 255, 0.03) 1px, transparent 1px)',
|
||||
},
|
||||
backgroundSize: {
|
||||
'grid': '20px 20px',
|
||||
},
|
||||
animation: {
|
||||
'scan': 'scan 3s ease-in-out infinite',
|
||||
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
||||
'fade-in': 'fade-in 0.5s ease-out',
|
||||
'slide-up': 'slide-up 0.4s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
'scan': {
|
||||
'0%, 100%': { transform: 'translateY(-100%)', opacity: '0' },
|
||||
'50%': { transform: 'translateY(100%)', opacity: '0.3' },
|
||||
},
|
||||
'pulse-glow': {
|
||||
'0%, 100%': { boxShadow: '0 0 5px rgba(0, 217, 255, 0.3)' },
|
||||
'50%': { boxShadow: '0 0 20px rgba(0, 217, 255, 0.6)' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'slide-up': {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-intel-darkest">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
|
||||
@@ -1,38 +1,825 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
/* Tactical Intelligence Dashboard Styles */
|
||||
/* IMPORTANT: This file MUST be imported in App.js */
|
||||
|
||||
* {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
/* Pulse animation for glowing dots - used by inline styles */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Base Colors - Modern Slate Foundation */
|
||||
--intel-darkest: #0F172A;
|
||||
--intel-dark: #1E293B;
|
||||
--intel-medium: #334155;
|
||||
--intel-accent: #0EA5E9; /* Sky Blue - professional cyan */
|
||||
--intel-warning: #F59E0B; /* Amber - sophisticated warning */
|
||||
--intel-danger: #EF4444; /* Modern Red - urgent but refined */
|
||||
--intel-success: #10B981; /* Emerald - professional green */
|
||||
--intel-grid: rgba(14, 165, 233, 0.08);
|
||||
|
||||
/* Text Colors with proper contrast */
|
||||
--text-primary: #F8FAFC;
|
||||
--text-secondary: #E2E8F0;
|
||||
--text-tertiary: #CBD5E1;
|
||||
--text-muted: #94A3B8;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0F172A;
|
||||
color: #E2E8F0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Utility Classes for Tailwind-style usage */
|
||||
.bg-intel-darkest { background-color: var(--intel-darkest); }
|
||||
.bg-intel-dark { background-color: var(--intel-dark); }
|
||||
.bg-intel-medium { background-color: var(--intel-medium); }
|
||||
.text-intel-accent { color: var(--intel-accent); }
|
||||
.text-intel-warning { color: var(--intel-warning); }
|
||||
.text-intel-danger { color: var(--intel-danger); }
|
||||
.text-intel-success { color: var(--intel-success); }
|
||||
.border-intel-accent { border-color: var(--intel-accent); }
|
||||
.border-intel-warning { border-color: var(--intel-warning); }
|
||||
.border-intel-danger { border-color: var(--intel-danger); }
|
||||
.border-intel-grid { border-color: var(--intel-grid); }
|
||||
|
||||
/* Grid background effect */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(14, 165, 233, 0.025) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* Monospace font for technical data */
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Glowing border effect */
|
||||
.glow-border {
|
||||
position: relative;
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
|
||||
.glow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
background: linear-gradient(45deg, transparent, rgba(14, 165, 233, 0.08), transparent);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.glow-border:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scanning line animation */
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
rgba(14, 165, 233, 0.6),
|
||||
transparent
|
||||
);
|
||||
animation: scan 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
@keyframes scan {
|
||||
0%, 100% {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(2000%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Card hover effects with refined depth */
|
||||
.intel-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.3);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.1),
|
||||
inset 0 -1px 0 rgba(14, 165, 233, 0.05);
|
||||
}
|
||||
|
||||
.intel-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
rgba(14, 165, 233, 0.08),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.intel-card:hover {
|
||||
border-color: rgba(14, 165, 233, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||
0 0 30px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.intel-card:hover::after {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Status badges with STRONG glow and contrast */
|
||||
.status-badge {
|
||||
position: relative;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
.status-critical {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.15) 100%);
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
color: #FCA5A5;
|
||||
text-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.status-critical::before {
|
||||
background: #EF4444;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.6), 0 0 6px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.status-high {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(245, 158, 11, 0.15) 100%);
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
color: #FCD34D;
|
||||
text-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.status-high::before {
|
||||
background: #F59E0B;
|
||||
box-shadow: 0 0 12px rgba(245, 158, 11, 0.6), 0 0 6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.status-medium {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2) 0%, rgba(14, 165, 233, 0.15) 100%);
|
||||
border-color: rgba(14, 165, 233, 0.6);
|
||||
color: #7DD3FC;
|
||||
text-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
.status-medium::before {
|
||||
background: #0EA5E9;
|
||||
box-shadow: 0 0 12px rgba(14, 165, 233, 0.6), 0 0 6px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
.status-low {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.15) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
color: #6EE7B7;
|
||||
text-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.status-low::before {
|
||||
background: #10B981;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.6), 0 0 6px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* Button styles with depth and glow */
|
||||
.intel-button {
|
||||
position: relative;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.intel-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.5s, height 0.5s;
|
||||
}
|
||||
|
||||
.intel-button:hover::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.intel-button-primary {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%);
|
||||
border-color: #0EA5E9;
|
||||
color: #38BDF8;
|
||||
text-shadow: 0 0 6px rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
|
||||
.intel-button-primary:hover {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%);
|
||||
box-shadow:
|
||||
0 0 20px rgba(14, 165, 233, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.intel-button-danger {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.1) 100%);
|
||||
border-color: #EF4444;
|
||||
color: #F87171;
|
||||
text-shadow: 0 0 6px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.intel-button-danger:hover {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%);
|
||||
box-shadow:
|
||||
0 0 20px rgba(239, 68, 68, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.intel-button-success {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.1) 100%);
|
||||
border-color: #10B981;
|
||||
color: #34D399;
|
||||
text-shadow: 0 0 6px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.intel-button-success:hover {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%);
|
||||
box-shadow:
|
||||
0 0 20px rgba(16, 185, 129, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Input fields with better contrast */
|
||||
.intel-input {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
color: #F8FAFC;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.2),
|
||||
0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.intel-input:focus {
|
||||
outline: none;
|
||||
border-color: #0EA5E9;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(14, 165, 233, 0.15),
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.15),
|
||||
0 4px 12px rgba(14, 165, 233, 0.1);
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
}
|
||||
|
||||
.intel-input::placeholder {
|
||||
color: rgba(226, 232, 240, 0.35);
|
||||
}
|
||||
|
||||
/* Stat cards with refined depth */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.35);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(14, 165, 233, 0.5);
|
||||
box-shadow:
|
||||
0 8px 20px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||
0 0 24px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #0EA5E9, transparent);
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(14, 165, 233, 0.5);
|
||||
}
|
||||
|
||||
/* Modal overlay with proper backdrop */
|
||||
.modal-overlay {
|
||||
background: rgba(10, 14, 39, 0.97);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Modal card enhancements */
|
||||
.intel-card.modal-card {
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 10px 30px rgba(0, 217, 255, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1E293B;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(14, 165, 233, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(14, 165, 233, 0.5);
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
.fade-in {
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse glow animation */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px currentColor;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Data table styling */
|
||||
.data-row {
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.data-row:hover {
|
||||
background: rgba(0, 217, 255, 0.06);
|
||||
border-bottom-color: rgba(0, 217, 255, 0.3);
|
||||
box-shadow: 0 2px 8px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Vendor entry cards - high contrast and depth */
|
||||
.vendor-card {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.9) 0%, rgba(30, 39, 73, 0.8) 100%);
|
||||
border: 1px solid rgba(0, 217, 255, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.vendor-card:hover {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
border-color: rgba(0, 217, 255, 0.4);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 217, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Document list items with depth */
|
||||
.document-item {
|
||||
background: linear-gradient(135deg, rgba(10, 14, 39, 0.9) 0%, rgba(19, 25, 55, 0.8) 100%);
|
||||
border: 1px solid rgba(0, 217, 255, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
background: linear-gradient(135deg, rgba(10, 14, 39, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
border-color: rgba(0, 217, 255, 0.3);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 217, 255, 0.12),
|
||||
0 2px 6px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* JIRA ticket items with proper contrast */
|
||||
.jira-ticket-item {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.85) 0%, rgba(30, 39, 73, 0.75) 100%);
|
||||
border: 1px solid rgba(255, 184, 0, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.jira-ticket-item:hover {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.85) 100%);
|
||||
border-color: rgba(255, 184, 0, 0.35);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 184, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* CVE Header card with depth */
|
||||
.cve-header {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cve-header:hover {
|
||||
background: linear-gradient(135deg, rgba(30, 39, 73, 0.95) 0%, rgba(42, 52, 88, 0.9) 100%);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
border: 2px solid rgba(14, 165, 233, 0.1);
|
||||
border-top-color: #0EA5E9;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Tooltip with enhanced styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: linear-gradient(135deg, #334155 0%, #475569 100%);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #F8FAFC;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 0 16px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.tooltip:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Enhanced heading glow */
|
||||
h1.text-intel-accent,
|
||||
h2.text-intel-accent,
|
||||
h3.text-intel-accent {
|
||||
text-shadow:
|
||||
0 0 16px rgba(14, 165, 233, 0.3),
|
||||
0 0 32px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
/* Enhanced border glow for featured cards */
|
||||
.border-intel-accent {
|
||||
box-shadow: 0 0 12px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.border-intel-warning {
|
||||
box-shadow: 0 0 12px rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.border-intel-danger {
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* Quick lookup section enhancement */
|
||||
.quick-lookup-card {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 0 40px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Vendor Cards - nested depth */
|
||||
.vendor-card {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow:
|
||||
0 3px 10px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.vendor-card:hover {
|
||||
border-color: rgba(14, 165, 233, 0.4);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(14, 165, 233, 0.12),
|
||||
0 3px 10px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Document items - recessed appearance */
|
||||
.document-item {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(20, 28, 48, 0.98) 100%);
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: rgba(14, 165, 233, 0.35);
|
||||
background: linear-gradient(135deg, rgba(20, 28, 48, 1) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
||||
0 2px 8px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* Knowledge Base Content Area */
|
||||
.kb-content-area {
|
||||
min-height: 400px;
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Markdown Content Styling */
|
||||
.markdown-content {
|
||||
color: #E2E8F0;
|
||||
line-height: 1.7;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #0EA5E9;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #10B981;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #F59E0B;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #94A3B8;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1rem;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: #0EA5E9;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(14, 165, 233, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: #38BDF8;
|
||||
border-bottom-color: #38BDF8;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #E2E8F0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #0EA5E9;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
background: rgba(14, 165, 233, 0.05);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0EA5E9;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content td {
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.markdown-content tr:hover {
|
||||
background: rgba(14, 165, 233, 0.05);
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(14, 165, 233, 0.2);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-content strong {
|
||||
color: #F8FAFC;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content em {
|
||||
color: #CBD5E1;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
1951
frontend/src/App.js
1951
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ const ACTION_BADGES = {
|
||||
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_edit: { bg: 'bg-orange-100', text: 'text-orange-800' },
|
||||
cve_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||
};
|
||||
|
||||
|
||||
167
frontend/src/components/CalendarWidget.js
Normal file
167
frontend/src/components/CalendarWidget.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
function toLocalDateStr(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ onDateClick }) {
|
||||
const today = new Date();
|
||||
const todayStr = toLocalDateStr(today);
|
||||
|
||||
const [calYear, setCalYear] = useState(today.getFullYear());
|
||||
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
||||
|
||||
// Map of "YYYY-MM-DD" → count of findings due that day
|
||||
const [dueDates, setDueDates] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!data?.findings) return;
|
||||
const counts = {};
|
||||
data.findings.forEach((f) => {
|
||||
if (f.dueDate) {
|
||||
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
||||
}
|
||||
});
|
||||
setDueDates(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
||||
else { setCalMonth((m) => m - 1); }
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
||||
else { setCalMonth((m) => m + 1); }
|
||||
};
|
||||
|
||||
// Build cell array: null = padding, number = day of month
|
||||
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const cells = [
|
||||
...Array(firstDow).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
||||
|
||||
const hasDueDatesThisMonth = cells.some((day) => {
|
||||
if (!day) return false;
|
||||
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return !!dueDates[ds];
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Month navigation */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
|
||||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||
{MONTH_NAMES[calMonth]} {calYear}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
>
|
||||
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day-of-week headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||
{cells.map((day, idx) => {
|
||||
if (!day) return <div key={idx} />;
|
||||
|
||||
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isToday = dateStr === todayStr;
|
||||
const dueCount = dueDates[dateStr] || 0;
|
||||
const hasDue = dueCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '2px', padding: '3px 1px',
|
||||
borderRadius: '4px',
|
||||
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||
cursor: hasDue ? 'pointer' : 'default',
|
||||
transition: hasDue ? 'background 0.15s' : undefined,
|
||||
}}
|
||||
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||
}}>
|
||||
{day}
|
||||
</span>
|
||||
{/* Red dot indicator for due dates */}
|
||||
{hasDue ? (
|
||||
<div style={{
|
||||
width: '4px', height: '4px', borderRadius: '50%',
|
||||
background: '#EF4444',
|
||||
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
) : (
|
||||
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend — only shown when there are due dates this month */}
|
||||
{hasDueDatesThisMonth && (
|
||||
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Ivanti finding due
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
@@ -0,0 +1,384 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
const [phase, setPhase] = useState('idle'); // idle, uploading, success, error
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('General');
|
||||
const [result, setResult] = useState(null);
|
||||
const [existingArticles, setExistingArticles] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fetch existing articles on mount
|
||||
useEffect(() => {
|
||||
fetchExistingArticles();
|
||||
}, []);
|
||||
|
||||
const fetchExistingArticles = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||
const data = await response.json();
|
||||
setExistingArticles(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching articles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
const allowedExtensions = ['.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.json', '.yaml', '.yml'];
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
setError('File type not allowed. Please upload: PDF, Markdown, Text, Office docs, or HTML files.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setError('');
|
||||
|
||||
// Auto-populate title from filename if empty
|
||||
if (!title) {
|
||||
const filename = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||
setTitle(filename);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !title.trim()) {
|
||||
setError('Please provide both a title and file');
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase('uploading');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title.trim());
|
||||
formData.append('description', description.trim());
|
||||
formData.append('category', category);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
setPhase('success');
|
||||
|
||||
// Refresh the list of existing articles
|
||||
await fetchExistingArticles();
|
||||
|
||||
// Notify parent to refresh
|
||||
if (onUpdate) onUpdate();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (id, filename) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Error downloading file:', err);
|
||||
setError('Failed to download file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id, articleTitle) => {
|
||||
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Delete failed');
|
||||
|
||||
// Refresh the list
|
||||
await fetchExistingArticles();
|
||||
|
||||
// Notify parent to refresh
|
||||
if (onUpdate) onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error deleting article:', err);
|
||||
setError('Failed to delete article');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setPhase('idle');
|
||||
setSelectedFile(null);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory('General');
|
||||
setResult(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryColor = (cat) => {
|
||||
const colors = {
|
||||
'General': '#94A3B8',
|
||||
'Policy': '#0EA5E9',
|
||||
'Procedure': '#10B981',
|
||||
'Guide': '#F59E0B',
|
||||
'Reference': '#8B5CF6'
|
||||
};
|
||||
return colors[cat] || '#94A3B8';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Knowledge Base</h2>
|
||||
<button onClick={onClose} className="modal-close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="modal-body">
|
||||
{/* Idle Phase - Upload Form */}
|
||||
{phase === 'idle' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Inventory Management Policy"
|
||||
className="intel-input w-full"
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this document..."
|
||||
className="intel-input w-full"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="intel-input w-full"
|
||||
>
|
||||
<option value="General">General</option>
|
||||
<option value="Policy">Policy</option>
|
||||
<option value="Procedure">Procedure</option>
|
||||
<option value="Guide">Guide</option>
|
||||
<option value="Reference">Reference</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Document File *
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||||
onChange={handleFileSelect}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||
Selected: {selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !title.trim()}
|
||||
className={`intel-button w-full ${selectedFile && title.trim() ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
<UploadIcon className="w-4 h-4 mr-2" />
|
||||
Upload Document
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading Phase */}
|
||||
{phase === 'uploading' && (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Uploading document...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Phase */}
|
||||
{phase === 'success' && result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||
{result.title} has been added to the knowledge base.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Upload Another Document
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Phase */}
|
||||
{phase === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Articles Section */}
|
||||
{(phase === 'idle' || phase === 'success') && existingArticles.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||
Existing Documents ({existingArticles.length})
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{existingArticles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="intel-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="w-4 h-4 flex-shrink-0" style={{ color: getCategoryColor(article.category) }} />
|
||||
<p className="font-medium truncate" style={{ color: '#E2E8F0' }}>
|
||||
{article.title}
|
||||
</p>
|
||||
</div>
|
||||
{article.description && (
|
||||
<p className="text-sm mb-2 line-clamp-2" style={{ color: '#94A3B8' }}>
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: `${getCategoryColor(article.category)}33`,
|
||||
color: getCategoryColor(article.category)
|
||||
}}
|
||||
>
|
||||
{article.category}
|
||||
</span>
|
||||
<span>{formatDate(article.created_at)}</span>
|
||||
<span>{formatFileSize(article.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownload(article.id, article.file_name)}
|
||||
className="intel-button intel-button-small intel-button-success"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(article.id, article.title)}
|
||||
className="intel-button intel-button-small"
|
||||
style={{ borderColor: '#EF4444', color: '#EF4444' }}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticleContent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article.id]);
|
||||
|
||||
const fetchArticleContent = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/content`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch article content');
|
||||
|
||||
const text = await response.text();
|
||||
setContent(text);
|
||||
} catch (err) {
|
||||
console.error('Error fetching article content:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/download`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = article.file_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Error downloading file:', err);
|
||||
setError('Failed to download file');
|
||||
}
|
||||
};
|
||||
|
||||
const isMarkdown = article.file_name?.endsWith('.md');
|
||||
const isText = article.file_name?.endsWith('.txt');
|
||||
const isPDF = article.file_name?.endsWith('.pdf');
|
||||
const isImage = /\.(jpg|jpeg|png|gif|bmp)$/i.test(article.file_name || '');
|
||||
|
||||
const getCategoryColor = (cat) => {
|
||||
const colors = {
|
||||
'General': '#94A3B8',
|
||||
'Policy': '#0EA5E9',
|
||||
'Procedure': '#10B981',
|
||||
'Guide': '#F59E0B',
|
||||
'Reference': '#8B5CF6'
|
||||
};
|
||||
return colors[cat] || '#94A3B8';
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
|
||||
padding: '1.5rem',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
|
||||
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
|
||||
{article.title}
|
||||
</h2>
|
||||
</div>
|
||||
{article.description && (
|
||||
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||
<span
|
||||
className="px-2 py-1 rounded"
|
||||
style={{
|
||||
background: `${getCategoryColor(article.category)}33`,
|
||||
color: getCategoryColor(article.category),
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{article.category}
|
||||
</span>
|
||||
<span>Created: {formatDate(article.created_at)}</span>
|
||||
{article.created_by_username && (
|
||||
<span>By: {article.created_by_username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="intel-button intel-button-small"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="intel-button intel-button-small"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="kb-content-area">
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Loading document...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Failed to Load Document</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Markdown Rendering */}
|
||||
{isMarkdown && (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plain Text */}
|
||||
{isText && !isMarkdown && (
|
||||
<pre
|
||||
className="text-sm p-4 rounded overflow-auto"
|
||||
style={{
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
color: '#E2E8F0',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
maxHeight: '600px'
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* PDF */}
|
||||
{isPDF && (
|
||||
<div className="w-full" style={{ height: '700px' }}>
|
||||
<iframe
|
||||
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||
title={article.title}
|
||||
className="w-full h-full rounded"
|
||||
style={{
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
background: 'rgba(15, 23, 42, 0.8)'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{isImage && (
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||
alt={article.title}
|
||||
className="max-w-full h-auto rounded"
|
||||
style={{ border: '1px solid rgba(14, 165, 233, 0.3)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other file types */}
|
||||
{!isMarkdown && !isText && !isPDF && !isImage && (
|
||||
<div className="text-center py-12">
|
||||
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#94A3B8' }} />
|
||||
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||
Preview not available for this file type.
|
||||
</p>
|
||||
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader, AlertCircle, Lock, User } from 'lucide-react';
|
||||
import { AlertCircle, Lock, User } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function LoginForm() {
|
||||
@@ -24,57 +24,60 @@ export default function LoginForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-8">
|
||||
<div className="min-h-screen bg-intel-darkest grid-bg flex items-center justify-center p-4 relative overflow-hidden fade-in">
|
||||
{/* Scanning line effect */}
|
||||
<div className="scan-line"></div>
|
||||
|
||||
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#0476D9] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-intel-accent to-intel-accent-dim rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg" style={{boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)'}}>
|
||||
<Lock className="w-8 h-8 text-intel-darkest" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">CVE Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to access the dashboard</p>
|
||||
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">CVE INTEL</h1>
|
||||
<p className="text-gray-400 mt-2 font-sans text-sm">Threat Intelligence Access Portal</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
<div className="mb-6 p-4 bg-intel-danger/10 border border-intel-danger/30 rounded flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-intel-danger flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-gray-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label htmlFor="username" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<User className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
className="intel-input w-full pl-10"
|
||||
placeholder="Enter username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label htmlFor="password" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<Lock className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
className="intel-input w-full pl-10"
|
||||
placeholder="Enter password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
@@ -83,22 +86,22 @@ export default function LoginForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
className="w-full intel-button intel-button-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Signing in...
|
||||
<div className="loading-spinner w-5 h-5"></div>
|
||||
<span className="font-mono uppercase tracking-wider">Authenticating...</span>
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
<span className="font-mono uppercase tracking-wider">Access System</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Default admin credentials: admin / admin123
|
||||
<div className="mt-6 pt-6 border-t border-intel-grid">
|
||||
<p className="text-sm text-gray-500 text-center font-mono">
|
||||
Default: <span className="text-intel-accent">admin</span> / <span className="text-intel-accent">admin123</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
127
frontend/src/components/NavDrawer.js
Normal file
127
frontend/src/components/NavDrawer.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
];
|
||||
|
||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.65)',
|
||||
backdropFilter: 'blur(3px)',
|
||||
zIndex: 50
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 51,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
padding: '1.5rem'
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||
STEAM
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||
Security Dashboard
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||
>
|
||||
<X style={{ width: '20px', height: '20px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { onNavigate(id); onClose(); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||
padding: '0.75rem 0.875rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||
background: active ? `${color}18` : 'transparent',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
transition: 'background 0.15s, border-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Icon box */}
|
||||
<div style={{
|
||||
width: '36px', height: '36px', flexShrink: 0,
|
||||
borderRadius: '0.375rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}40`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||
</div>
|
||||
|
||||
{/* Label + description */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
color: active ? color : '#CBD5E1',
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active indicator dot */}
|
||||
{active && (
|
||||
<div style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
marginTop: 'auto', paddingTop: '1rem',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
NTS Threat Intelligence
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
460
frontend/src/components/pages/ExportsPage.js
Normal file
460
frontend/src/components/pages/ExportsPage.js
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const EXC_PATTERN = /EXC-\d+/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function classifyFinding(f) {
|
||||
if (f.workflow != null) return 'fp';
|
||||
if (EXC_PATTERN.test(f.note || '')) return 'archer';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const dateStr = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function autoFit(ws, rows) {
|
||||
if (!rows[0]) return;
|
||||
ws['!cols'] = rows[0].map((_, ci) => ({
|
||||
wch: Math.min(60, Math.max(10, ...rows.map(r => String(r[ci] ?? '').length)))
|
||||
}));
|
||||
}
|
||||
|
||||
function toXLSX(rows, sheetName, filename) {
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
autoFit(ws, rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
function toMultiXLSX(sheets, filename) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
sheets.forEach(({ name, rows }) => {
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
autoFit(ws, rows);
|
||||
XLSX.utils.book_append_sheet(wb, ws, String(name || 'Unknown').slice(0, 31));
|
||||
});
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
function toCSV(rows, filename) {
|
||||
const csv = rows.map(row =>
|
||||
row.map(cell => {
|
||||
const s = String(cell ?? '');
|
||||
return (s.includes(',') || s.includes('"') || s.includes('\n'))
|
||||
? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}).join(',')
|
||||
).join('\r\n');
|
||||
triggerDownload(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), filename);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
const FINDING_HEADERS = [
|
||||
'Finding ID', 'Title', 'Severity Score', 'Severity Group',
|
||||
'Host', 'IP Address', 'DNS', 'Due Date', 'SLA Status',
|
||||
'Business Unit', 'FP# ID', 'FP# State', 'Last Found', 'CVEs', 'Notes',
|
||||
];
|
||||
|
||||
function findingRow(f) {
|
||||
return [
|
||||
f.id,
|
||||
f.title,
|
||||
f.severity != null ? Number(f.severity).toFixed(2) : '',
|
||||
f.vrrGroup ?? '',
|
||||
f.overrides?.hostName ?? f.hostName ?? '',
|
||||
f.ipAddress ?? '',
|
||||
f.overrides?.dns ?? f.dns ?? '',
|
||||
f.dueDate ?? '',
|
||||
f.slaStatus ?? '',
|
||||
f.buOwnership ?? '',
|
||||
f.workflow?.id ?? '',
|
||||
f.workflow?.state ?? '',
|
||||
f.lastFoundOn ?? '',
|
||||
(f.cves || []).join(', '),
|
||||
f.note ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API fetchers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchFindings() {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.findings || [];
|
||||
}
|
||||
|
||||
async function fetchCVEs(status) {
|
||||
const url = status ? `${API_BASE}/cves?status=${encodeURIComponent(status)}` : `${API_BASE}/cves`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CVE list returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchArcher() {
|
||||
const res = await fetch(`${API_BASE}/archer-tickets`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Archer tickets returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCompliance() {
|
||||
const res = await fetch(`${API_BASE}/cves/compliance`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Compliance data returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
function ExportCard({ color, colorRgb, icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||
border: `1px solid rgba(${colorRgb},0.2)`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<Icon style={{ width: '18px', height: '18px', color, flexShrink: 0 }} />
|
||||
<h3 style={{
|
||||
fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '600',
|
||||
color, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 12px rgba(${colorRgb},0.4)`, margin: 0,
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', margin: 0, lineHeight: 1.6 }}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabled }) {
|
||||
const isLoading = loading === exportKey;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!!loading || disabled}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.45rem 0.875rem',
|
||||
background: `rgba(${colorRgb},0.08)`,
|
||||
border: `1px solid rgba(${colorRgb},0.25)`,
|
||||
borderRadius: '0.375rem',
|
||||
color: isLoading ? '#64748B' : color,
|
||||
cursor: (!!loading || disabled) ? 'not-allowed' : 'pointer',
|
||||
opacity: (!!loading && !isLoading) ? 0.45 : 1,
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'opacity 0.15s, color 0.15s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isLoading
|
||||
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
|
||||
: <Download style={{ width: '12px', height: '12px', flexShrink: 0 }} />
|
||||
}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange, color, colorRgb }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<div
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
width: '32px', height: '18px', borderRadius: '9px',
|
||||
background: checked ? color : 'rgba(255,255,255,0.1)',
|
||||
border: `1px solid rgba(${colorRgb},0.4)`,
|
||||
position: 'relative', transition: 'background 0.2s',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: '2px',
|
||||
left: checked ? '14px' : '2px',
|
||||
width: '12px', height: '12px', borderRadius: '50%',
|
||||
background: '#E2E8F0',
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B' }}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ExportsPage() {
|
||||
const [loading, setLoading] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [cveStatus, setCveStatus] = useState('');
|
||||
const [missingOnly, setMissingOnly] = useState(false);
|
||||
|
||||
const run = useCallback(async (key, fn) => {
|
||||
setLoading(key);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
console.error('[Export]', e);
|
||||
setError(e.message || 'Export failed — check console for details');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Card 1: Ivanti Findings ----
|
||||
|
||||
const exportFullFindings = () => run('ivanti-full', async () => {
|
||||
const findings = await fetchFindings();
|
||||
toXLSX(
|
||||
[FINDING_HEADERS, ...findings.map(findingRow)],
|
||||
'All Findings',
|
||||
`findings-full-${dateStr()}.xlsx`,
|
||||
);
|
||||
});
|
||||
|
||||
const exportPending = () => run('ivanti-pending', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportOverdue = () => run('ivanti-overdue', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const today = dateStr();
|
||||
const rows = findings.filter(f => {
|
||||
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
|
||||
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
|
||||
}).map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportByBU = () => run('ivanti-bu', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const groups = {};
|
||||
findings.forEach(f => {
|
||||
const bu = f.buOwnership || 'Unknown';
|
||||
if (!groups[bu]) groups[bu] = [];
|
||||
groups[bu].push(f);
|
||||
});
|
||||
const sheets = Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, rows]) => ({ name, rows: [FINDING_HEADERS, ...rows.map(findingRow)] }));
|
||||
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [FINDING_HEADERS] });
|
||||
toMultiXLSX(sheets, `findings-by-bu-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 2: FP Workflow Summary ----
|
||||
|
||||
const exportFPSummary = () => run('fp-summary', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const fpMap = {};
|
||||
findings.forEach(f => {
|
||||
if (!f.workflow?.id) return;
|
||||
const id = f.workflow.id;
|
||||
if (!fpMap[id]) fpMap[id] = { id, state: f.workflow.state || '', count: 0, hosts: new Set(), bus: new Set(), cves: new Set() };
|
||||
fpMap[id].count++;
|
||||
const host = f.overrides?.hostName ?? f.hostName;
|
||||
if (host) fpMap[id].hosts.add(host);
|
||||
if (f.buOwnership) fpMap[id].bus.add(f.buOwnership);
|
||||
(f.cves || []).forEach(c => fpMap[id].cves.add(c));
|
||||
});
|
||||
const headers = ['FP# ID', 'State', 'Finding Count', 'Hosts', 'Business Units', 'CVEs'];
|
||||
const rows = Object.values(fpMap)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(e => [e.id, e.state, e.count, [...e.hosts].join(', '), [...e.bus].join(', '), [...e.cves].join(', ')]);
|
||||
toXLSX([headers, ...rows], 'FP Workflows', `fp-workflow-summary-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 3: CVE Database ----
|
||||
|
||||
const exportCVEs = (fmt) => run(`cves-${fmt}`, async () => {
|
||||
const data = await fetchCVEs(cveStatus);
|
||||
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Published Date', 'Description', 'Documents'];
|
||||
const rows = data.map(c => [c.cve_id, c.vendor, c.severity, c.status, c.published_date ?? '', c.description ?? '', c.document_count ?? 0]);
|
||||
if (fmt === 'csv') {
|
||||
toCSV([headers, ...rows], `cve-database-${dateStr()}.csv`);
|
||||
} else {
|
||||
toXLSX([headers, ...rows], 'CVEs', `cve-database-${dateStr()}.xlsx`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Card 4: Archer Tickets ----
|
||||
|
||||
const exportArcher = () => run('archer', async () => {
|
||||
const data = await fetchArcher();
|
||||
const headers = ['EXC Number', 'Status', 'CVE ID', 'Vendor', 'Archer URL', 'Created'];
|
||||
const rows = data.map(t => [t.exc_number, t.status, t.cve_id ?? '', t.vendor ?? '', t.archer_url ?? '', t.created_at ?? '']);
|
||||
toXLSX([headers, ...rows], 'Archer Tickets', `archer-tickets-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 5: Compliance Report ----
|
||||
|
||||
const exportCompliance = () => run('compliance', async () => {
|
||||
const data = await fetchCompliance();
|
||||
const filtered = missingOnly ? data.filter(r => r.compliance_status !== 'Complete') : data;
|
||||
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs', 'Compliance Status'];
|
||||
const rows = filtered.map(r => [r.cve_id, r.vendor, r.severity, r.status, r.total_documents, r.advisory_count, r.email_count, r.screenshot_count, r.compliance_status]);
|
||||
toXLSX([headers, ...rows], 'Compliance', `compliance-report-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
|
||||
{/* Page header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<Download style={{ width: '20px', height: '20px', color: '#8B5CF6' }} />
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139,92,246,0.4)', margin: 0 }}>
|
||||
Exports
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#EF4444', flex: 1 }}>{error}</span>
|
||||
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#EF4444', padding: 0 }}>
|
||||
<X style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))', gap: '1.5rem' }}>
|
||||
|
||||
{/* ── Card 1: Ivanti Findings ── */}
|
||||
<ExportCard
|
||||
color="#F59E0B" colorRgb="245,158,11"
|
||||
icon={BarChart2}
|
||||
title="Ivanti Host Findings"
|
||||
description="Export host findings from the local cache. Four report types: full dump, findings with no action taken, overdue SLA, and a per-business-unit multi-sheet workbook."
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||
<ExportBtn label="Full Dump" exportKey="ivanti-full" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportFullFindings} />
|
||||
<ExportBtn label="Pending Action" exportKey="ivanti-pending" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportPending} />
|
||||
<ExportBtn label="Overdue SLA" exportKey="ivanti-overdue" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportOverdue} />
|
||||
<ExportBtn label="By Business Unit" exportKey="ivanti-bu" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportByBU} />
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
"By Business Unit" creates one sheet per BU in a single workbook.
|
||||
</p>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 2: FP Workflow Summary ── */}
|
||||
<ExportCard
|
||||
color="#0EA5E9" colorRgb="14,165,233"
|
||||
icon={FileText}
|
||||
title="FP Workflow Summary"
|
||||
description="One row per unique FP# ticket ID. Shows state, how many findings belong to that ticket, which hosts are affected, and which CVEs are involved. Use this for status meetings."
|
||||
>
|
||||
<ExportBtn label="Export FP Summary (.xlsx)" exportKey="fp-summary" loading={loading} color="#0EA5E9" colorRgb="14,165,233" onClick={exportFPSummary} />
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 3: CVE Database ── */}
|
||||
<ExportCard
|
||||
color="#22C55E" colorRgb="34,197,94"
|
||||
icon={Shield}
|
||||
title="CVE Database"
|
||||
description="Export the full CVE registry. Optionally filter by status to produce a focused remediation backlog. Includes document count per entry."
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', whiteSpace: 'nowrap' }}>Status</span>
|
||||
<select
|
||||
value={cveStatus}
|
||||
onChange={e => setCveStatus(e.target.value)}
|
||||
disabled={!!loading}
|
||||
style={{
|
||||
background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)',
|
||||
borderRadius: '0.25rem', color: '#CBD5E1', padding: '0.25rem 0.5rem',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', cursor: 'pointer', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Addressed">Addressed</option>
|
||||
<option value="Resolved">Resolved</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<ExportBtn label="Export CSV" exportKey="cves-csv" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('csv')} />
|
||||
<ExportBtn label="Export .xlsx" exportKey="cves-xlsx" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('xlsx')} />
|
||||
</div>
|
||||
</div>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 4: Archer Tickets ── */}
|
||||
<ExportCard
|
||||
color="#F97316" colorRgb="249,115,22"
|
||||
icon={Tag}
|
||||
title="Archer Risk Acceptance Tickets"
|
||||
description="Export all Archer EXC exception tickets with their linked CVE IDs, vendors, statuses, and Archer URLs. Useful for risk acceptance reporting and audits."
|
||||
>
|
||||
<ExportBtn label="Export Archer Tickets (.xlsx)" exportKey="archer" loading={loading} color="#F97316" colorRgb="249,115,22" onClick={exportArcher} />
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 5: Compliance Report ── */}
|
||||
<ExportCard
|
||||
color="#EF4444" colorRgb="239,68,68"
|
||||
icon={CheckCircle}
|
||||
title="Document Compliance Report"
|
||||
description="Shows document coverage per CVE/vendor pair. A row is marked Complete when an advisory document has been uploaded; otherwise Missing Required Docs. Filter to missing-only to generate a gap list."
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Toggle
|
||||
label="Missing required docs only"
|
||||
checked={missingOnly}
|
||||
onChange={setMissingOnly}
|
||||
color="#EF4444"
|
||||
colorRgb="239,68,68"
|
||||
/>
|
||||
<ExportBtn label="Export Compliance Report (.xlsx)" exportKey="compliance" loading={loading} color="#EF4444" colorRgb="239,68,68" onClick={exportCompliance} />
|
||||
</div>
|
||||
</ExportCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
25
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} />
|
||||
</div>
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
||||
Knowledge Base
|
||||
</h2>
|
||||
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
||||
Under construction — coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2336
frontend/src/components/pages/ReportingPage.js
Normal file
2336
frontend/src/components/pages/ReportingPage.js
Normal file
File diff suppressed because it is too large
Load Diff
155
ivantiAPI.py
Normal file
155
ivantiAPI.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# Ivanti API class/wrapper | Evan Compton (P2886385), updated 11/13/2025
|
||||
|
||||
### ! README | IMPORTANT INFORMATION ! ###
|
||||
# requires an "Ivanti_config.ini" file in the same directory
|
||||
# edit "Ivanti_config_template.ini", then save as "Ivanti_config.ini"
|
||||
|
||||
### ? CODE PURPOSE ? ###
|
||||
# the primary purpose of this class/wrapper is to export data as a Pandas Dataframe and/or a CSV file
|
||||
# this class primarily targets these endpoints: host, tag, hostFinding, vulnerability
|
||||
# it should work on other endpoints as well, but the 4 above are the only ones tested
|
||||
# usage examples of this class are at the end of this file
|
||||
|
||||
# library imports
|
||||
import requests, urllib3, configparser, pandas as pd
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3 import Retry
|
||||
|
||||
# fix (ignore) SSL verification...
|
||||
# Charter-specific issue; feel free to fix this if you can...
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
# Ivanti API class
|
||||
class Ivanti:
|
||||
def __init__(self, config_file='./Ivanti_config.ini'):
|
||||
# read our config file
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_file)
|
||||
|
||||
# set up environment & auth
|
||||
PLATFORM = config.get('platform', 'url') + config.get('platform', 'api_ver')
|
||||
IVANTI_API_KEY = config.get('secrets', 'api_key')
|
||||
self.CLIENT_ID = config.get('platform', 'client_id')
|
||||
self.URL_BASE = f'{PLATFORM}/client/{self.CLIENT_ID}'
|
||||
|
||||
# universal header for our requests
|
||||
self.header = {
|
||||
'x-api-key': IVANTI_API_KEY,
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
# dictionaries for filters and fields, sorted with keys by endpoint prefixes
|
||||
self.filters = {}
|
||||
self.fields = {}
|
||||
return
|
||||
|
||||
# function used for HTTP requests- thank you, Ivanti... useful code
|
||||
def request(max_retries=5, backoff_factor=0.5, status_forcelist=(419,429)):
|
||||
"""
|
||||
Create a Requests session that uses automatic retries.
|
||||
:param max_retries: Maximum number of retries to attempt
|
||||
:type max_retries: int
|
||||
:param backoff_factor: Backoff factor used to calculate time between retries.
|
||||
:type backoff_factor: float
|
||||
:param status_forcelist: A tuple containing the response status codes that should trigger a retry.
|
||||
:type status_forcelist: tuple
|
||||
:return: Requests Session
|
||||
:rtype: Requests Session Object
|
||||
"""
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=max_retries,
|
||||
read=max_retries,
|
||||
connect=max_retries,
|
||||
backoff_factor=backoff_factor,
|
||||
status_forcelist=status_forcelist,
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount('https://', adapter)
|
||||
return session
|
||||
|
||||
# retrieve all filters for an endpoint (tag, host, etc)
|
||||
def get_filters(self, endp='tag'):
|
||||
URL_FILTERS = f'{self.URL_BASE}/{endp}/filter'
|
||||
self.last_resp = self.request().get(URL_FILTERS, headers=self.header, verify=False)
|
||||
self.filters[endp] = self.last_resp.json()
|
||||
return self.filters[endp]
|
||||
|
||||
# retrieve all fields for an endpoint (tag, host, etc)
|
||||
def get_fields(self, endp='tag'):
|
||||
URL_FIELDS = f'{self.URL_BASE}/{endp}/export/template'
|
||||
self.last_resp = self.request().get(URL_FIELDS, headers=self.header, verify=False)
|
||||
self.fields[endp] = self.last_resp.json()['exportableFields']
|
||||
return self.fields[endp]
|
||||
|
||||
# this uses the "{subject}/search" endpoint instead of "{subject}/export"
|
||||
def search(self, endp='tag', save=None, pages=None, size=750):
|
||||
'''
|
||||
Uses the "/client/{client_id}/{subject}/search" endpoint to export data as JSON.
|
||||
:param endp: String for endpoint name; host, tag, group, etc. (default: "tag")
|
||||
:param save: String for filename to save, end with ".csv" (default: none)
|
||||
:param pages: Integer to limit the number of pages to pull (default: all pages)
|
||||
:param size: Integer defining how many records to pull per page (default: 750 records)
|
||||
:return: Pandas DataFrame
|
||||
'''
|
||||
# most endpoints follow the same URL structure and usage pattern
|
||||
# filters and fields dont matter for searches- only for exports!
|
||||
URL_SEARCH = f'{self.URL_BASE}/{endp}/search'
|
||||
body = {
|
||||
'projection': 'basic', # can also be set to 'detail'
|
||||
'sort': [
|
||||
{
|
||||
'field': 'id',
|
||||
'direction': 'ASC'
|
||||
}
|
||||
],
|
||||
'page': 0,
|
||||
'size': size
|
||||
}
|
||||
|
||||
# post a search, get first page
|
||||
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f'[!] ERROR: Search failed.\n- code: {resp.status_code}\n- text: {resp.text}')
|
||||
totalPages = int(resp.json()['page']['totalPages'])
|
||||
totalRecords = int(resp.json()['page']['totalElements'])
|
||||
body['page'] = int(resp.json()['page']['number']) + 1
|
||||
msg = f'[?] Search requested for "{endp}"\n[?] Total pages: {totalPages}\n[?] Total records: {totalRecords}\n[?] Batch size: {size}'
|
||||
if pages:
|
||||
msg += f'\n[?] Page limit: {pages} pages'
|
||||
print(msg)
|
||||
|
||||
# limit results?
|
||||
if pages:
|
||||
totalPages = pages
|
||||
|
||||
# loop until the last page
|
||||
subject = f'{endp[:-1]}ies' if endp.endswith('y') else f'{endp}s'
|
||||
data = []
|
||||
while body['page'] < totalPages:
|
||||
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||
body['page'] = int(resp.json()['page']['number']) + 1
|
||||
data.extend(resp.json()['_embedded'][subject])
|
||||
print(f'[?] Page progress: [{body["page"]}/{totalPages}] ({len(data)} total records retrieved)\r', end='')
|
||||
print(f'\n[+] Search completed. {len(data)} records retrieved!')
|
||||
|
||||
# make a nice dataframe, save file if wanted, return the frame
|
||||
df = pd.DataFrame(data)
|
||||
if save:
|
||||
df.to_csv(save, index=False)
|
||||
return df
|
||||
|
||||
### ? EXAMPLE USAGE ? ###
|
||||
# configure the connection and auth, create an instance object
|
||||
#API = Ivanti('./Ivanti_config.ini')
|
||||
|
||||
# the "search" function goes to the "/client/{clientID}/{subject}/search" endpoint
|
||||
#df = API.search('host', save='IvantiHostsTest_5pages.csv', pages=5)
|
||||
#df = API.search('tag', save='IvantiTagsTest_5pages.csv', pages=5)
|
||||
#df = API.search('hostFinding', save='IvantiHostFindingsTest_5pages.csv', pages=5)
|
||||
#df = API.search('vulnerability', save='IvantiVulnerabilitiesTest_5pages.csv', pages=5)
|
||||
|
||||
# you can also retrieve all possible filters and exportable fields per subject
|
||||
#filters = API.get_fields('host')
|
||||
#fields = API.get_filters('tag')
|
||||
297
plan.md
297
plan.md
@@ -1,297 +0,0 @@
|
||||
# NVD Lookup + Retroactive Sync — Implementation Plan
|
||||
|
||||
## Overview
|
||||
Two capabilities on `feature/nvd-lookup` branch:
|
||||
1. **Auto-fill on Add CVE** (DONE, stashed) — onBlur NVD lookup fills description/severity/date in the Add CVE modal
|
||||
2. **Sync with NVD** (TO DO) — bulk tool for editors/admins to retroactively update existing CVE entries from NVD, with per-CVE choice to keep or replace description
|
||||
|
||||
## Current State
|
||||
|
||||
### Git State
|
||||
- **Branch:** `feature/nvd-lookup` (branched from master post-audit-merge)
|
||||
- **Stash:** `stash@{0}` contains the auto-fill implementation (4 files)
|
||||
- **Master** now has audit logging (merged from feature/audit on 2026-01-30)
|
||||
- Offsite repo is up to date through the feature/audit merge to master
|
||||
|
||||
### What's in the Stash
|
||||
The stash contains working NVD auto-fill code that needs to be popped and conflict-resolved before continuing:
|
||||
|
||||
**`backend/routes/nvdLookup.js` (NEW file)**
|
||||
- Factory function: `createNvdLookupRouter(db, requireAuth)`
|
||||
- `GET /lookup/:cveId` endpoint
|
||||
- Validates CVE ID format (regex: `CVE-YYYY-NNNNN`)
|
||||
- Calls `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...`
|
||||
- 10-second timeout via `AbortSignal.timeout(10000)`
|
||||
- Optional `apiKey` header from `NVD_API_KEY` env var
|
||||
- CVSS severity cascade: v3.1 → v3.0 → v2.0
|
||||
- Maps NVD uppercase severity to app format (CRITICAL→Critical, etc.)
|
||||
- Returns: `{ description, severity, published_date }`
|
||||
|
||||
**`backend/server.js` (MODIFIED)**
|
||||
- Adds `const createNvdLookupRouter = require('./routes/nvdLookup');`
|
||||
- Adds `app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));`
|
||||
|
||||
**`frontend/src/App.js` (MODIFIED)**
|
||||
- New state: `nvdLoading`, `nvdError`, `nvdAutoFilled`
|
||||
- New function: `lookupNVD(cveId)` — calls backend, auto-fills form fields
|
||||
- CVE ID input: `onBlur` triggers lookup, `onChange` resets NVD feedback
|
||||
- Spinner (Loader icon) in CVE ID field while loading
|
||||
- Green "Auto-filled from NVD" with CheckCircle on success
|
||||
- Amber warning with AlertCircle on errors (non-blocking)
|
||||
- Description only fills if currently empty; severity + published_date always update
|
||||
- NVD state resets on modal close (X, Cancel) and form submit
|
||||
|
||||
**`backend/.env.example` (MODIFIED)**
|
||||
- Adds `NVD_API_KEY=` with comment about rate limits
|
||||
|
||||
### Stash Conflict Resolution
|
||||
Popping the stash will conflict in `server.js` because master now has audit imports that didn't exist when the stash was created. Resolution:
|
||||
|
||||
The conflict is in the imports section. Keep ALL existing audit lines from master:
|
||||
```js
|
||||
const createAuditLogRouter = require('./routes/auditLog');
|
||||
const logAudit = require('./helpers/auditLog');
|
||||
```
|
||||
AND add the NVD line:
|
||||
```js
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
```
|
||||
|
||||
Similarly, keep the audit route mount and add the NVD mount after it:
|
||||
```js
|
||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
||||
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||
```
|
||||
|
||||
Then `git add backend/server.js` to mark resolved and `git stash drop`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Resolve Stash + Rebase onto Master
|
||||
|
||||
```bash
|
||||
git checkout feature/nvd-lookup
|
||||
git rebase master # Get audit changes into the branch
|
||||
git stash pop # Apply NVD changes (will conflict in server.js)
|
||||
# Resolve conflict in server.js as described above
|
||||
git add backend/server.js
|
||||
git stash drop
|
||||
```
|
||||
|
||||
Verify: `backend/routes/nvdLookup.js` exists, `server.js` has both audit AND NVD imports/mounts.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Backend — New Endpoints in `server.js`
|
||||
|
||||
### 2A: `GET /api/cves/distinct-ids`
|
||||
Place BEFORE `GET /api/cves/check/:cveId` (to avoid route param conflict):
|
||||
```js
|
||||
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
||||
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json(rows.map(r => r.cve_id));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2B: `POST /api/cves/nvd-sync`
|
||||
Place after the existing `PATCH /api/cves/:cveId/status`:
|
||||
```js
|
||||
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { updates } = req.body;
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No updates provided' });
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
const errors = [];
|
||||
let completed = 0;
|
||||
|
||||
db.serialize(() => {
|
||||
updates.forEach((entry) => {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (entry.description !== null && entry.description !== undefined) {
|
||||
fields.push('description = ?');
|
||||
values.push(entry.description);
|
||||
}
|
||||
if (entry.severity !== null && entry.severity !== undefined) {
|
||||
fields.push('severity = ?');
|
||||
values.push(entry.severity);
|
||||
}
|
||||
if (entry.published_date !== null && entry.published_date !== undefined) {
|
||||
fields.push('published_date = ?');
|
||||
values.push(entry.published_date);
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
completed++;
|
||||
if (completed === updates.length) sendResponse();
|
||||
return;
|
||||
}
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(entry.cve_id);
|
||||
|
||||
db.run(
|
||||
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
|
||||
values,
|
||||
function(err) {
|
||||
if (err) {
|
||||
errors.push({ cve_id: entry.cve_id, error: err.message });
|
||||
} else {
|
||||
updated += this.changes;
|
||||
}
|
||||
completed++;
|
||||
if (completed === updates.length) sendResponse();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function sendResponse() {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'cve_nvd_sync',
|
||||
entityType: 'cve',
|
||||
entityId: null,
|
||||
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
const result = { message: 'NVD sync completed', updated };
|
||||
if (errors.length > 0) result.errors = errors;
|
||||
res.json(result);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**How "keep existing description" works:** If the user chooses to keep the existing description, the frontend sends `description: null` for that CVE. The backend skips null fields, so the description is not overwritten. Severity and published_date are always sent (auto-update).
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Frontend — New `NvdSyncModal.js` Component
|
||||
|
||||
**File:** `frontend/src/components/NvdSyncModal.js`
|
||||
|
||||
### Props
|
||||
```jsx
|
||||
<NvdSyncModal onClose={fn} onSyncComplete={fn} />
|
||||
```
|
||||
|
||||
### Phase Machine
|
||||
| Phase | What's shown |
|
||||
|-------|-------------|
|
||||
| `idle` | CVE count + "Fetch NVD Data" button |
|
||||
| `fetching` | Progress bar, current CVE being fetched, cancel button |
|
||||
| `review` | Comparison table with per-CVE description choice |
|
||||
| `applying` | Spinner |
|
||||
| `done` | Summary (X updated, Y errors) + Close button |
|
||||
|
||||
### Fetching Logic
|
||||
- Iterate CVE IDs sequentially
|
||||
- Call `GET /api/nvd/lookup/:cveId` for each
|
||||
- 7-second delay between requests (safe for 5 req/30s without API key)
|
||||
- On 429: wait 35 seconds, retry once
|
||||
- On 404: mark as "Not found in NVD" (gray, skipped)
|
||||
- On timeout/error: mark with warning (skipped)
|
||||
- Support cancellation via AbortController
|
||||
|
||||
### Comparison Table Columns
|
||||
| Column | Content |
|
||||
|--------|---------|
|
||||
| CVE ID | The identifier |
|
||||
| Status | Icon: check=found, warning=error, dash=no changes |
|
||||
| Severity | `[Current] → [NVD]` with color badges, or "No change" |
|
||||
| Published Date | `Current → NVD` or "No change" |
|
||||
| Description | Truncated preview with expand toggle. Current (red bg) vs NVD (green bg) when different |
|
||||
| Choice | Radio: "Keep existing" (default) / "Use NVD" — only shown when descriptions differ |
|
||||
|
||||
### Bulk Controls
|
||||
Above the table:
|
||||
- Summary: `Found: N | Up to date: N | Changes: N | Not in NVD: N | Errors: N`
|
||||
- Bulk toggle: "Keep All Existing" / "Use All NVD Descriptions"
|
||||
|
||||
Below the table:
|
||||
- "Apply N Changes" button (count updates dynamically)
|
||||
- "Cancel" button
|
||||
|
||||
### Apply Logic
|
||||
Build updates array:
|
||||
- For each CVE with NVD data (no error):
|
||||
- Always include `severity` and `published_date` if different from current
|
||||
- Include `description` only if user chose "Use NVD" — otherwise send `null`
|
||||
- Skip CVEs where nothing changed
|
||||
- POST to `/api/cves/nvd-sync`
|
||||
- On success: call `onSyncComplete()` to refresh CVE list, then show done phase
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Frontend — App.js Integration
|
||||
|
||||
Minimal changes following `AuditLog`/`UserManagement` pattern:
|
||||
|
||||
1. **Import:** Add `NvdSyncModal` and `RefreshCw` icon
|
||||
2. **State:** Add `const [showNvdSync, setShowNvdSync] = useState(false);`
|
||||
3. **Header button** (next to "Add CVE/Vendor", visible to editors/admins):
|
||||
```jsx
|
||||
{canWrite() && (
|
||||
<button onClick={() => setShowNvdSync(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Sync with NVD
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
4. **Modal render** (alongside other modals):
|
||||
```jsx
|
||||
{showNvdSync && (
|
||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: AuditLog Badge
|
||||
|
||||
**File:** `frontend/src/components/AuditLog.js`
|
||||
|
||||
Add to the `ACTION_BADGES` object:
|
||||
```js
|
||||
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: .env.example (already in stash)
|
||||
```
|
||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||
NVD_API_KEY=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action | Lines Changed (est.) |
|
||||
|------|--------|---------------------|
|
||||
| `backend/server.js` | Modify | +40 (NVD mount + 2 new endpoints) |
|
||||
| `backend/routes/nvdLookup.js` | From stash | 0 (already complete) |
|
||||
| `backend/.env.example` | From stash | +3 |
|
||||
| `frontend/src/components/NvdSyncModal.js` | New | ~350-400 |
|
||||
| `frontend/src/App.js` | Modify | +10 (import, state, button, modal) |
|
||||
| `frontend/src/components/AuditLog.js` | Modify | +1 (badge entry) |
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
1. Pop stash, resolve conflict, verify `nvdLookup.js` and server.js are correct
|
||||
2. Test NVD lookup via curl: `curl -b cookie.txt http://localhost:3001/api/nvd/lookup/CVE-2024-3094`
|
||||
3. Test distinct-ids: `curl -b cookie.txt http://localhost:3001/api/cves/distinct-ids`
|
||||
4. Open Add CVE modal, type CVE ID, tab out → verify auto-fill works
|
||||
5. Click "Sync with NVD" button → modal opens with CVE count
|
||||
6. Click "Fetch NVD Data" → progress bar, rate-limited fetching
|
||||
7. Review comparison table → verify diffs shown correctly
|
||||
8. Toggle description choices, click "Apply" → verify database updated
|
||||
9. Confirm main CVE list refreshes with new data
|
||||
10. Check audit log for `cve_nvd_sync` entry
|
||||
@@ -1,7 +1,5 @@
|
||||
# Authentication Feature - Test Cases
|
||||
|
||||
**Feature Branch:** feature/login
|
||||
**Date:** 2026-01-28
|
||||
**Tester:** _______________
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user