Compare commits
21 Commits
26abd55e0f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd | |||
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 | |||
| 0d67a99c7e | |||
| bf3d01becf | |||
| 9384ded04f | |||
| 0c9c3b5514 | |||
| 4a50cd100b | |||
| c22a3a70ab | |||
| 626d0cac3a | |||
| ba4d16396c | |||
| 83d944fa70 |
@@ -3,6 +3,8 @@
|
||||
## Role
|
||||
You are the frontend specialist for the CVE Dashboard project. You build and maintain the React UI, handle client-side state, manage API communication, and implement user-facing features.
|
||||
|
||||
**IMPORTANT:** When creating new UI components or implementing frontend features, you should use the `frontend-design` skill to ensure production-grade, distinctive design quality. Invoke this skill using the Skill tool with `skill: "frontend-design"`.
|
||||
|
||||
## Project Context
|
||||
|
||||
### Tech Stack
|
||||
|
||||
25
.claude/instructions.md
Normal file
25
.claude/instructions.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Project Instructions
|
||||
|
||||
## Token Usage & Efficiency
|
||||
Follow the guidelines in `.claude/optimization.md` for:
|
||||
- When to use subagents vs main conversation
|
||||
- Model selection (Haiku vs Sonnet)
|
||||
- Token preservation strategies
|
||||
- Rate limiting rules
|
||||
|
||||
## Project Context
|
||||
This is a CVE (Common Vulnerabilities and Exposures) dashboard application for tracking security vulnerabilities, vendors, and JIRA tickets.
|
||||
|
||||
## Security Focus
|
||||
All code changes should consider:
|
||||
- Input validation
|
||||
- SQL injection prevention
|
||||
- XSS protection
|
||||
- Authentication/authorization
|
||||
|
||||
## Frontend Development
|
||||
When working on frontend features or UI components:
|
||||
- Use the `frontend-design` skill for new component creation and UI implementation
|
||||
- This skill provides production-grade design quality and avoids generic AI aesthetics
|
||||
- Invoke it using: `Skill` tool with `skill: "frontend-design"`
|
||||
- The skill will guide implementation with distinctive, polished code patterns
|
||||
143
.claude/optimization.md
Normal file
143
.claude/optimization.md
Normal file
@@ -0,0 +1,143 @@
|
||||
OPTIMIZATION.md - Token Usage & Subagent Strategy
|
||||
|
||||
## SUBAGENT USAGE STRATEGY
|
||||
|
||||
Subagents run in separate contexts and preserve main conversation tokens.
|
||||
|
||||
### When to Use Subagents
|
||||
|
||||
**Use Subagents for:**
|
||||
- Large-scale codebase exploration and analysis
|
||||
- Complex multi-step investigations across many files
|
||||
- Detailed code pattern searches and refactoring analysis
|
||||
- Gathering comprehensive information before main conversation work
|
||||
- When total tokens would exceed 30,000 in main conversation
|
||||
|
||||
**Keep in Main Conversation:**
|
||||
- Direct file edits (1-3 files)
|
||||
- Simple code changes and debugging
|
||||
- Architecture decisions
|
||||
- Security reviews and approvals
|
||||
- User-facing responses and recommendations
|
||||
- Questions requiring reasoning about codebase
|
||||
- Frontend UI work (use `frontend-design` skill for new components)
|
||||
|
||||
### Subagent Types & When to Use
|
||||
|
||||
**Explore Agent** (Haiku 3.5)
|
||||
- Codebase exploration and file discovery
|
||||
- Pattern searching across large codebases
|
||||
- Gathering information about file structure
|
||||
- Finding references and relationships
|
||||
|
||||
**General-Purpose Agent** (Haiku 3.5)
|
||||
- Multi-step code analysis tasks
|
||||
- Summarizing findings from exploration
|
||||
- Complex searches requiring multiple strategies
|
||||
- Collecting data for main conversation decisions
|
||||
|
||||
---
|
||||
|
||||
## MODEL SELECTION STRATEGY
|
||||
|
||||
### Main Conversation (Sonnet 4.5)
|
||||
- **Always use Sonnet 4.5 in main conversation**
|
||||
- Direct file edits and modifications
|
||||
- Architecture and design decisions
|
||||
- Security analysis and approvals
|
||||
- Complex reasoning and recommendations
|
||||
- Final user responses
|
||||
|
||||
### Subagent Models
|
||||
|
||||
**Haiku 4.5** (Default for subagents)
|
||||
- Code exploration and pattern searching
|
||||
- File discovery and structure analysis
|
||||
- Simple codebase investigations
|
||||
- Gathering information and summarizing
|
||||
- Task: Use Haiku first for subagent work
|
||||
|
||||
**Sonnet 4.5** (For subagents - when needed)
|
||||
- Security-critical analysis within subagents
|
||||
- Complex architectural decisions needed in exploration
|
||||
- High-risk code analysis
|
||||
- When exploration requires advanced reasoning
|
||||
|
||||
---
|
||||
|
||||
## RATE LIMITING GUIDANCE
|
||||
|
||||
### API Call Throttling
|
||||
- 5 seconds minimum between API calls
|
||||
- 10 seconds minimum between web searches
|
||||
- Batch similar work whenever possible
|
||||
- If you hit 429 error: STOP and wait 5 minutes
|
||||
|
||||
### Budget Management
|
||||
- Track tokens used across all agents
|
||||
- Main conversation should stay under 100,000 tokens
|
||||
- Subagent work can extend to 50,000 tokens per agent
|
||||
- Batch multiple subagent tasks together when possible
|
||||
|
||||
---
|
||||
|
||||
## TOKEN PRESERVATION RULES
|
||||
|
||||
### Best Practices for Long-Running Conversations
|
||||
|
||||
**In Main Conversation:**
|
||||
1. Start with subagent for exploration (saves ~20,000 tokens)
|
||||
2. Request subagent summarize findings
|
||||
3. Use summary to inform main conversation edits/decisions
|
||||
4. Keep main conversation focused on decisions and actions
|
||||
|
||||
**Information Gathering:**
|
||||
- Use subagents to explore before asking for analysis in main conversation
|
||||
- Have subagent provide condensed summaries (250-500 words max)
|
||||
- Main conversation uses summary + provides feedback/decisions
|
||||
|
||||
**File Editing:**
|
||||
- For <3 files: Keep in main conversation
|
||||
- For 3+ files: Split between subagent (finding/analysis) and main (approval/execution)
|
||||
- Simple edits (1-5 lines per file): Main conversation
|
||||
- Complex refactoring (10+ lines per file): Subagent analysis + main approval
|
||||
|
||||
**Code Review Workflow:**
|
||||
1. Subagent explores and analyzes code patterns
|
||||
2. Subagent flags issues and suggests improvements
|
||||
3. Main conversation reviews suggestions
|
||||
4. Main conversation executes approved changes
|
||||
|
||||
### Token Budget Allocation Example
|
||||
- Main conversation: 0-100,000 tokens (soft limit)
|
||||
- Per subagent task: 0-50,000 tokens
|
||||
- Critical work (security): Use Sonnet in main conversation
|
||||
- Exploratory work: Use Explore agent (Haiku) in subagent
|
||||
|
||||
---
|
||||
|
||||
## DECISION TREE
|
||||
|
||||
```
|
||||
Is this a direct file edit request?
|
||||
├─ YES (1-3 files, <10 lines each) → Main conversation
|
||||
├─ NO
|
||||
└─ Is this exploratory analysis?
|
||||
├─ YES (finding files, patterns) → Use Explore agent (Haiku)
|
||||
├─ NO
|
||||
└─ Is this complex multi-step work?
|
||||
├─ YES (3+ steps, many files) → Use General agent (Haiku)
|
||||
├─ NO
|
||||
└─ Is this security-critical?
|
||||
├─ YES → Main conversation (Sonnet)
|
||||
└─ NO → Subagent (Haiku) or Main conversation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY
|
||||
|
||||
**Main Conversation (You):** Architecture, decisions, edits, reviews
|
||||
**Subagents:** Exploration, analysis, information gathering
|
||||
**Sonnet 4.5:** Security, complexity, final decisions
|
||||
**Haiku 4.5:** Exploration, gathering, analysis support
|
||||
79
COLOR_SCHEME_MODERNIZATION.md
Normal file
79
COLOR_SCHEME_MODERNIZATION.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# CVE Dashboard - Color Scheme Modernization
|
||||
|
||||
## Overview
|
||||
Successfully modernized the color scheme from retro 80s/neon arcade aesthetic to a professional, sophisticated tactical intelligence platform look.
|
||||
|
||||
## Color Palette Changes
|
||||
|
||||
### Before (Neon/Retro)
|
||||
- **Accent**: `#00D9FF` - Bright cyan (too neon)
|
||||
- **Warning**: `#FFB800` - Bright yellow/orange (too saturated)
|
||||
- **Danger**: `#FF3366` - Neon pink/red
|
||||
- **Success**: `#00FF88` - Bright green (too bright)
|
||||
- **Background Dark**: `#0A0E27`, `#131937`, `#1E2749`
|
||||
|
||||
### After (Modern Professional)
|
||||
- **Accent**: `#0EA5E9` - Sky Blue (professional, refined cyan)
|
||||
- **Warning**: `#F59E0B` - Amber (sophisticated, warm)
|
||||
- **Danger**: `#EF4444` - Modern Red (urgent but refined)
|
||||
- **Success**: `#10B981` - Emerald (professional green)
|
||||
- **Background Dark**: `#0F172A`, `#1E293B`, `#334155` (Tailwind Slate palette)
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
### Refinement Approach
|
||||
1. **Reduced Glow Intensity**: Lowered opacity and blur radius on all glows from 0.9 to 0.4-0.5
|
||||
2. **Subtler Borders**: Changed from 3px bright borders to 1.5-2px refined borders
|
||||
3. **Professional Gradients**: Updated background gradients to use slate tones instead of stark blues
|
||||
4. **Sophisticated Shadows**: Reduced shadow intensity while maintaining depth
|
||||
5. **Text Shadow Refinement**: Reduced from aggressive glows to subtle halos
|
||||
|
||||
### Key Changes
|
||||
|
||||
#### Severity Badges
|
||||
- **Critical**: Neon pink → Modern red with refined glow
|
||||
- **High**: Bright yellow → Amber with warm tones
|
||||
- **Medium**: Bright cyan → Sky blue professional
|
||||
- **Low**: Bright green → Emerald sophisticated
|
||||
|
||||
#### Interactive Elements
|
||||
- **Buttons**: Reduced glow from 25px to 20px radius, lowered opacity
|
||||
- **Input Fields**: More subtle focus states, refined borders
|
||||
- **Cards**: Gentler hover effects, professional elevation
|
||||
- **Stat Cards**: Refined top accent lines, subtle glows
|
||||
|
||||
#### Layout Components
|
||||
- **Wiki Panel**: Updated to emerald accent with professional borders
|
||||
- **Calendar**: Sky blue accent with refined styling
|
||||
- **Tickets Panel**: Amber accent maintaining urgency without neon feel
|
||||
- **CVE Cards**: Slate-based gradients with professional depth
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files Modified
|
||||
1. **App.css**: Updated all CSS variables, component styles, and utility classes
|
||||
2. **App.js**: Updated inline STYLES object and all JSX color references
|
||||
|
||||
### CSS Variables Updated
|
||||
```css
|
||||
--intel-darkest: #0F172A
|
||||
--intel-dark: #1E293B
|
||||
--intel-medium: #334155
|
||||
--intel-accent: #0EA5E9
|
||||
--intel-warning: #F59E0B
|
||||
--intel-danger: #EF4444
|
||||
--intel-success: #10B981
|
||||
--intel-grid: rgba(14, 165, 233, 0.08)
|
||||
```
|
||||
|
||||
### Maintained Features
|
||||
✓ Pulsing button effects on hover/click
|
||||
✓ Scanning line animation
|
||||
✓ Card hover elevations
|
||||
✓ Badge glow dots
|
||||
✓ Grid background effect
|
||||
✓ Three-column layout
|
||||
✓ All interactive functionality
|
||||
|
||||
## Result
|
||||
The dashboard now presents a modern, professional tactical intelligence platform aesthetic while preserving all the visual interest, depth, and functionality that made the original design engaging. The color scheme feels premium and sophisticated rather than arcade-like, suitable for enterprise security operations.
|
||||
294
DESIGN_SYSTEM.md
Normal file
294
DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: February 10, 2026
|
||||
**Version**: 2.0 (Modern Professional Redesign)
|
||||
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 /home/admin/cve-dashboard/backend
|
||||
node server.js
|
||||
```
|
||||
|
||||
2. **Frontend:**
|
||||
```bash
|
||||
cd /home/admin/cve-dashboard/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": {}
|
||||
}
|
||||
93
backend/helpers/excelProcessor.js
Normal file
93
backend/helpers/excelProcessor.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Process vulnerability report Excel file by splitting CVE IDs into separate rows
|
||||
* @param {string} inputPath - Path to original Excel file
|
||||
* @param {string} outputPath - Path for processed Excel file
|
||||
* @returns {Promise<{original_rows: number, processed_rows: number, output_path: string}>}
|
||||
*/
|
||||
function processVulnerabilityReport(inputPath, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const scriptPath = path.join(__dirname, '..', 'scripts', 'split_cve_report.py');
|
||||
|
||||
// Verify script exists
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return reject(new Error(`Python script not found: ${scriptPath}`));
|
||||
}
|
||||
|
||||
// Verify input file exists
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
return reject(new Error(`Input file not found: ${inputPath}`));
|
||||
}
|
||||
|
||||
const python = spawn('python3', [scriptPath, inputPath, outputPath]);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
// 30 second timeout
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
python.kill();
|
||||
reject(new Error('Processing timed out. File may be too large or corrupted.'));
|
||||
}, 30000);
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
python.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (timedOut) return;
|
||||
|
||||
if (code !== 0) {
|
||||
// Parse Python error messages
|
||||
if (stderr.includes('Sheet') && stderr.includes('not found')) {
|
||||
return reject(new Error('Invalid Excel file. Expected "Vulnerabilities" sheet with "CVE ID" column.'));
|
||||
}
|
||||
if (stderr.includes('pandas') || stderr.includes('openpyxl')) {
|
||||
return reject(new Error('Python dependencies missing. Run: pip3 install pandas openpyxl'));
|
||||
}
|
||||
return reject(new Error(`Python script failed: ${stderr || 'Unknown error'}`));
|
||||
}
|
||||
|
||||
// Parse output for row counts
|
||||
const originalMatch = stdout.match(/Original rows:\s*(\d+)/);
|
||||
const newMatch = stdout.match(/New rows:\s*(\d+)/);
|
||||
|
||||
if (!originalMatch || !newMatch) {
|
||||
return reject(new Error('Failed to parse row counts from Python output'));
|
||||
}
|
||||
|
||||
// Verify output file was created
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
return reject(new Error('Processed file was not created'));
|
||||
}
|
||||
|
||||
resolve({
|
||||
original_rows: parseInt(originalMatch[1]),
|
||||
processed_rows: parseInt(newMatch[1]),
|
||||
output_path: outputPath
|
||||
});
|
||||
});
|
||||
|
||||
python.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
if (err.code === 'ENOENT') {
|
||||
reject(new Error('Python 3 is required but not found. Please install Python.'));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { processVulnerabilityReport };
|
||||
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!');
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
59
backend/migrations/add_weekly_reports_table.js
Normal file
59
backend/migrations/add_weekly_reports_table.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Migration: Add weekly_reports table for vulnerability report uploads
|
||||
|
||||
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_weekly_reports_table');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS weekly_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_date DATE NOT NULL,
|
||||
week_label VARCHAR(50),
|
||||
original_filename VARCHAR(255),
|
||||
processed_filename VARCHAR(255),
|
||||
original_file_path VARCHAR(500),
|
||||
processed_file_path VARCHAR(500),
|
||||
row_count_original INTEGER,
|
||||
row_count_processed INTEGER,
|
||||
uploaded_by INTEGER,
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_current BOOLEAN DEFAULT 0,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating weekly_reports table:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created weekly_reports table');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_date
|
||||
ON weekly_reports(upload_date DESC)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating date index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on upload_date');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_current
|
||||
ON weekly_reports(is_current)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating current index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on is_current');
|
||||
console.log('\nMigration completed successfully!');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
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;
|
||||
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;
|
||||
261
backend/routes/weeklyReports.js
Normal file
261
backend/routes/weeklyReports.js
Normal file
@@ -0,0 +1,261 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const { processVulnerabilityReport } = require('../helpers/excelProcessor');
|
||||
|
||||
function createWeeklyReportsRouter(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 week label
|
||||
function getWeekLabel(date) {
|
||||
const now = new Date();
|
||||
const uploadDate = new Date(date);
|
||||
const daysDiff = Math.floor((now - uploadDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff < 7) {
|
||||
return "This week's report";
|
||||
} else if (daysDiff < 14) {
|
||||
return "Last week's report";
|
||||
} else {
|
||||
const month = uploadDate.getMonth() + 1;
|
||||
const day = uploadDate.getDate();
|
||||
const year = uploadDate.getFullYear();
|
||||
return `Week of ${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year}`;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/weekly-reports/upload - Upload and process vulnerability report
|
||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), upload.single('file'), async (req, res) => {
|
||||
const uploadedFile = req.file;
|
||||
|
||||
if (!uploadedFile) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
const ext = path.extname(uploadedFile.originalname).toLowerCase();
|
||||
if (ext !== '.xlsx') {
|
||||
fs.unlinkSync(uploadedFile.path); // Clean up temp file
|
||||
return res.status(400).json({ error: 'Only .xlsx files are allowed' });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||
const reportsDir = path.join(__dirname, '..', 'uploads', 'weekly_reports');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(reportsDir)) {
|
||||
fs.mkdirSync(reportsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const originalFilename = `${timestamp}_original_${sanitizedName}`;
|
||||
const processedFilename = `${timestamp}_processed_${sanitizedName}`;
|
||||
const originalPath = path.join(reportsDir, originalFilename);
|
||||
const processedPath = path.join(reportsDir, processedFilename);
|
||||
|
||||
try {
|
||||
// Move uploaded file to permanent location
|
||||
fs.renameSync(uploadedFile.path, originalPath);
|
||||
|
||||
// Process the file with Python script
|
||||
const result = await processVulnerabilityReport(originalPath, processedPath);
|
||||
|
||||
const uploadDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Update previous current reports to not current
|
||||
db.run('UPDATE weekly_reports SET is_current = 0 WHERE is_current = 1', (err) => {
|
||||
if (err) {
|
||||
console.error('Error updating previous current reports:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Insert new report record
|
||||
const insertSql = `
|
||||
INSERT INTO weekly_reports (
|
||||
upload_date, week_label, original_filename, processed_filename,
|
||||
original_file_path, processed_file_path, row_count_original,
|
||||
row_count_processed, uploaded_by, is_current
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`;
|
||||
|
||||
const weekLabel = getWeekLabel(uploadDate);
|
||||
|
||||
db.run(
|
||||
insertSql,
|
||||
[
|
||||
uploadDate,
|
||||
weekLabel,
|
||||
sanitizedName,
|
||||
processedFilename,
|
||||
originalPath,
|
||||
processedPath,
|
||||
result.original_rows,
|
||||
result.processed_rows,
|
||||
req.user.id
|
||||
],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error inserting weekly report:', err);
|
||||
return res.status(500).json({ error: 'Failed to save report metadata' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'UPLOAD_WEEKLY_REPORT',
|
||||
'weekly_reports',
|
||||
this.lastID,
|
||||
JSON.stringify({ filename: sanitizedName, rows: result.processed_rows }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: this.lastID,
|
||||
original_rows: result.original_rows,
|
||||
processed_rows: result.processed_rows,
|
||||
week_label: weekLabel
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Clean up files on error
|
||||
if (fs.existsSync(originalPath)) fs.unlinkSync(originalPath);
|
||||
if (fs.existsSync(processedPath)) fs.unlinkSync(processedPath);
|
||||
|
||||
console.error('Error processing vulnerability report:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to process report' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/weekly-reports - List all reports
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const sql = `
|
||||
SELECT id, upload_date, week_label, original_filename, processed_filename,
|
||||
row_count_original, row_count_processed, is_current, uploaded_at
|
||||
FROM weekly_reports
|
||||
ORDER BY upload_date DESC, uploaded_at DESC
|
||||
`;
|
||||
|
||||
db.all(sql, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching weekly reports:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch reports' });
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/weekly-reports/:id/download/:type - Download report file
|
||||
router.get('/:id/download/:type', requireAuth(db), (req, res) => {
|
||||
const { id, type } = req.params;
|
||||
|
||||
if (type !== 'original' && type !== 'processed') {
|
||||
return res.status(400).json({ error: 'Invalid download type. Use "original" or "processed"' });
|
||||
}
|
||||
|
||||
const sql = `SELECT original_file_path, processed_file_path, original_filename FROM weekly_reports WHERE id = ?`;
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching report:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch report' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Report not found' });
|
||||
}
|
||||
|
||||
const filePath = type === 'original' ? row.original_file_path : row.processed_file_path;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DOWNLOAD_WEEKLY_REPORT',
|
||||
'weekly_reports',
|
||||
id,
|
||||
JSON.stringify({ type }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
const downloadName = type === 'original' ? row.original_filename : row.original_filename.replace('.xlsx', '_processed.xlsx');
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/weekly-reports/:id - Delete report (admin only)
|
||||
router.delete('/:id', requireAuth(db), requireRole(db, 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT original_file_path, processed_file_path FROM weekly_reports WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching report for deletion:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch report' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Report not found' });
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
db.run('DELETE FROM weekly_reports WHERE id = ?', [id], (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting report:', err);
|
||||
return res.status(500).json({ error: 'Failed to delete report' });
|
||||
}
|
||||
|
||||
// Delete files
|
||||
if (fs.existsSync(row.original_file_path)) {
|
||||
fs.unlinkSync(row.original_file_path);
|
||||
}
|
||||
if (fs.existsSync(row.processed_file_path)) {
|
||||
fs.unlinkSync(row.processed_file_path);
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DELETE_WEEKLY_REPORT',
|
||||
'weekly_reports',
|
||||
id,
|
||||
null,
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createWeeklyReportsRouter;
|
||||
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
|
||||
83
backend/scripts/split_cve_report.py
Executable file
83
backend/scripts/split_cve_report.py
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CVE Report Splitter
|
||||
Splits multiple CVE IDs in a single row into separate rows for easier filtering and analysis.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def split_cve_report(input_file, output_file=None, sheet_name='Vulnerabilities', cve_column='CVE ID'):
|
||||
"""
|
||||
Split CVE IDs into separate rows.
|
||||
|
||||
Args:
|
||||
input_file: Path to input Excel file
|
||||
output_file: Path to output file (default: adds '_Split' to input filename)
|
||||
sheet_name: Name of sheet with vulnerability data (default: 'Vulnerabilities')
|
||||
cve_column: Name of column containing CVE IDs (default: 'CVE ID')
|
||||
"""
|
||||
input_path = Path(input_file)
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: File not found: {input_file}")
|
||||
sys.exit(1)
|
||||
|
||||
if output_file is None:
|
||||
output_file = input_path.parent / f"{input_path.stem}_Split{input_path.suffix}"
|
||||
|
||||
print(f"Reading: {input_file}")
|
||||
|
||||
try:
|
||||
df = pd.read_excel(input_file, sheet_name=sheet_name)
|
||||
except ValueError as e:
|
||||
print(f"Error: Sheet '{sheet_name}' not found in workbook")
|
||||
print(f"Available sheets: {pd.ExcelFile(input_file).sheet_names}")
|
||||
sys.exit(1)
|
||||
|
||||
if cve_column not in df.columns:
|
||||
print(f"Error: Column '{cve_column}' not found")
|
||||
print(f"Available columns: {list(df.columns)}")
|
||||
sys.exit(1)
|
||||
|
||||
original_rows = len(df)
|
||||
print(f"Original rows: {original_rows}")
|
||||
|
||||
# Split CVE IDs by comma
|
||||
df[cve_column] = df[cve_column].astype(str).str.split(',')
|
||||
|
||||
# Explode to create separate rows
|
||||
df_exploded = df.explode(cve_column)
|
||||
|
||||
# Clean up CVE IDs
|
||||
df_exploded[cve_column] = df_exploded[cve_column].str.strip()
|
||||
df_exploded = df_exploded[df_exploded[cve_column].notna()]
|
||||
df_exploded = df_exploded[df_exploded[cve_column] != 'nan']
|
||||
df_exploded = df_exploded[df_exploded[cve_column] != '']
|
||||
|
||||
# Reset index
|
||||
df_exploded = df_exploded.reset_index(drop=True)
|
||||
|
||||
new_rows = len(df_exploded)
|
||||
print(f"New rows: {new_rows}")
|
||||
print(f"Added {new_rows - original_rows} rows from splitting CVEs")
|
||||
|
||||
# Save output
|
||||
df_exploded.to_excel(output_file, index=False, sheet_name=sheet_name)
|
||||
print(f"\n✓ Success! Saved to: {output_file}")
|
||||
|
||||
return output_file
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 split_cve_report.py <input_file.xlsx> [output_file.xlsx]")
|
||||
print("\nExample:")
|
||||
print(" python3 split_cve_report.py 'Vulnerability Workbook.xlsx'")
|
||||
print(" python3 split_cve_report.py 'input.xlsx' 'output.xlsx'")
|
||||
sys.exit(1)
|
||||
|
||||
input_file = sys.argv[1]
|
||||
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
split_cve_report(input_file, output_file)
|
||||
@@ -18,6 +18,9 @@ const createUsersRouter = require('./routes/users');
|
||||
const createAuditLogRouter = require('./routes/auditLog');
|
||||
const logAudit = require('./helpers/auditLog');
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -32,7 +35,7 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||
// Allowed file extensions for document uploads (documents only, no executables)
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
||||
'.txt', '.csv', '.log', '.msg', '.eml',
|
||||
'.txt', '.md', '.csv', '.log', '.msg', '.eml',
|
||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.odt', '.ods', '.odp',
|
||||
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||
@@ -78,6 +81,7 @@ function isValidCveId(cveId) {
|
||||
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
|
||||
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
|
||||
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
|
||||
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||
|
||||
// Validate vendor name - printable chars, reasonable length
|
||||
function isValidVendor(vendor) {
|
||||
@@ -93,7 +97,7 @@ app.use((req, res, next) => {
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
@@ -105,7 +109,11 @@ app.use(cors({
|
||||
origin: CORS_ORIGINS,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
// Only parse JSON for requests with application/json content type
|
||||
app.use(express.json({
|
||||
limit: '1mb',
|
||||
type: 'application/json'
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
app.use('/uploads', express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
@@ -166,6 +174,15 @@ const upload = multer({
|
||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||
});
|
||||
|
||||
// Weekly reports routes (editor/admin for upload, all authenticated for download)
|
||||
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
||||
|
||||
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||
|
||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
@@ -857,7 +874,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => {
|
||||
// Get statistics (authenticated users)
|
||||
app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
const query = `
|
||||
SELECT
|
||||
SELECT
|
||||
COUNT(DISTINCT c.id) as total_cves,
|
||||
COUNT(DISTINCT CASE WHEN c.severity = 'Critical' THEN c.id END) as critical_count,
|
||||
COUNT(DISTINCT CASE WHEN c.status = 'Addressed' THEN c.id END) as addressed_count,
|
||||
@@ -867,7 +884,7 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id
|
||||
LEFT JOIN cve_document_status cd ON c.cve_id = cd.cve_id
|
||||
`;
|
||||
|
||||
|
||||
db.get(query, [], (err, row) => {
|
||||
if (err) {
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
@@ -876,6 +893,192 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== JIRA TICKET ENDPOINTS ==========
|
||||
|
||||
// Get all JIRA tickets (with optional filters)
|
||||
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (cve_id) {
|
||||
query += ' AND cve_id = ?';
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ' AND vendor = ?';
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// Create JIRA ticket
|
||||
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
}
|
||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||
}
|
||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
|
||||
const query = `
|
||||
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update JIRA ticket
|
||||
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// Validation
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating JIRA ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Delete JIRA ticket
|
||||
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||
if (deleteErr) {
|
||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1728
frontend/src/App.js
1728
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
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>
|
||||
|
||||
291
frontend/src/components/WeeklyReportModal.js
Normal file
291
frontend/src/components/WeeklyReportModal.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, Star } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
export default function WeeklyReportModal({ onClose }) {
|
||||
const [phase, setPhase] = useState('idle'); // idle, uploading, processing, success, error
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [result, setResult] = useState(null);
|
||||
const [existingReports, setExistingReports] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fetch existing reports on mount
|
||||
useEffect(() => {
|
||||
fetchExistingReports();
|
||||
}, []);
|
||||
|
||||
const fetchExistingReports = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/weekly-reports`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch reports');
|
||||
const data = await response.json();
|
||||
setExistingReports(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching reports:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.xlsx')) {
|
||||
setError('Please select an Excel file (.xlsx)');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setPhase('uploading');
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
try {
|
||||
setUploadProgress(50); // Simulated progress
|
||||
setPhase('processing');
|
||||
|
||||
const response = await fetch(`${API_BASE}/weekly-reports/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 reports
|
||||
await fetchExistingReports();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (id, type) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/weekly-reports/${id}/download/${type}`, {
|
||||
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 = `vulnerability_report_${type}.xlsx`;
|
||||
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 ${type} file`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setPhase('idle');
|
||||
setSelectedFile(null);
|
||||
setUploadProgress(0);
|
||||
setResult(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Weekly Vulnerability Report</h2>
|
||||
<button onClick={onClose} className="modal-close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="modal-body">
|
||||
{/* Idle Phase - File Selection */}
|
||||
{phase === 'idle' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Upload Excel File (.xlsx)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
onChange={handleFileSelect}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||
Selected: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile}
|
||||
className={`intel-button w-full ${selectedFile ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
<UploadIcon className="w-4 h-4 mr-2" />
|
||||
Upload & Process
|
||||
</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 file...</p>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 mt-4">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%`, background: '#0EA5E9' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing Phase */}
|
||||
{phase === 'processing' && (
|
||||
<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' }}>Processing vulnerability report...</p>
|
||||
<p className="text-sm mt-2" style={{ color: '#64748B' }}>Splitting CVE IDs into separate rows</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' }}>
|
||||
Original: {result.original_rows} rows → Processed: {result.processed_rows} rows
|
||||
<span className="ml-2" style={{ color: '#10B981' }}>
|
||||
(+{result.processed_rows - result.original_rows} rows from splitting CVEs)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleDownload(result.id, 'original')}
|
||||
className="intel-button flex-1"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download Original
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(result.id, 'processed')}
|
||||
className="intel-button intel-button-success flex-1"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download Processed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Upload Another Report
|
||||
</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 Reports Section */}
|
||||
{(phase === 'idle' || phase === 'success') && existingReports.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||
Previous Reports
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{existingReports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="intel-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{report.is_current && (
|
||||
<Star className="w-4 h-4 fill-current" style={{ color: '#F59E0B' }} />
|
||||
)}
|
||||
<p className="font-medium" style={{ color: report.is_current ? '#F59E0B' : '#94A3B8' }}>
|
||||
{report.week_label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: '#64748B' }}>
|
||||
{new Date(report.upload_date).toLocaleDateString()} •
|
||||
{report.row_count_original} → {report.row_count_processed} rows
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDownload(report.id, 'original')}
|
||||
className="intel-button intel-button-small"
|
||||
title="Download Original"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(report.id, 'processed')}
|
||||
className="intel-button intel-button-success intel-button-small"
|
||||
title="Download Processed"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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')
|
||||
Reference in New Issue
Block a user