Compare commits
89 Commits
1a578b23c1
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d48c109b3 | |||
| 18ad31228e | |||
| 3dcb91a1fc | |||
| 5102a2c5b4 | |||
| a0a8979c63 | |||
| 15ad207464 | |||
| b111273e5a | |||
| a7c74f625f | |||
| 8aef51b59a | |||
| d0087ba9b7 | |||
| 3d6062f3fa | |||
| 7af44608d0 | |||
| 3bb86e8369 | |||
| 4676279a72 | |||
| d3d86ddcf2 | |||
| 558c65807d | |||
| 518cb0a849 | |||
| b0adfa1bda | |||
| 7a2c56a11f | |||
| 89b1f57ef4 | |||
| 6bf6371e51 | |||
| 4d472b0aef | |||
| 887d11610e | |||
| 1520cc994b | |||
| 906066c7fa | |||
| b58bd0650a | |||
| ae04bc981e | |||
| 7314dc16cb | |||
| 602c75bf24 | |||
| 706ef19872 | |||
| 8392124df5 | |||
| fbe4333e9b | |||
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a | |||
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 | |||
| d3806e8ce3 | |||
| 931c42faeb | |||
| ea3b72db5c | |||
| d63e7cc9b9 | |||
| 37e183543a | |||
| 337ffe6f35 | |||
| 08c8c8a2a1 | |||
| 4ed7721a71 | |||
| 3fb20c147d | |||
| f2e6069c08 | |||
| c89404cf26 | |||
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd | |||
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 | |||
| 0d67a99c7e | |||
| bf3d01becf | |||
| 9384ded04f | |||
| 0c9c3b5514 | |||
| 4a50cd100b | |||
| c22a3a70ab | |||
| 626d0cac3a | |||
| ba4d16396c | |||
| 83d944fa70 | |||
| 26abd55e0f | |||
| eae4594baf | |||
| 84803a353e | |||
| d520c4ae41 | |||
| da109a6f8b | |||
| 260ae48f77 | |||
| fbdf05392a |
84
.gitea/issue_template/enhancement.yaml
Normal file
84
.gitea/issue_template/enhancement.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Enhancement
|
||||
about: Suggest an improvement to an existing feature or functionality
|
||||
title: "[Enhancement] "
|
||||
labels:
|
||||
- kind/enhancement
|
||||
- status/triage
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an improvement! This template is for enhancements to **existing** features. If you'd like to request a brand new feature, please use the Feature Request template instead.
|
||||
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: Describe how the existing feature currently works.
|
||||
placeholder: "Currently, when I do X, it works like..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-improvement
|
||||
attributes:
|
||||
label: Proposed Improvement
|
||||
description: Describe how you'd like the existing feature to be improved.
|
||||
placeholder: "I'd like it to also do Y, or behave differently by..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Why would this improvement be valuable? What problem does it solve?
|
||||
placeholder: "This would help because..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area of the Application
|
||||
description: Which part of the application does this enhancement relate to?
|
||||
options:
|
||||
- Dashboard / CVE List
|
||||
- CVE Details
|
||||
- Document Management
|
||||
- User Management
|
||||
- Authentication
|
||||
- Audit Logging
|
||||
- API
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this enhancement to your workflow?
|
||||
options:
|
||||
- Nice to have
|
||||
- Important
|
||||
- Critical
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives or Workarounds
|
||||
description: Are there any current workarounds or alternative approaches you've considered?
|
||||
placeholder: "Currently I work around this by..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or mockups about the enhancement here.
|
||||
validations:
|
||||
required: false
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -37,9 +37,16 @@ frontend.pid
|
||||
|
||||
# Temporary files
|
||||
backend/uploads/temp/
|
||||
claude.md
|
||||
claude_status.md
|
||||
feature_request*.md
|
||||
|
||||
# Planning docs
|
||||
docs/aeo-compliance-ui-plan.md
|
||||
docs/aeo-compliance-wireframe.md
|
||||
|
||||
# AI tooling config
|
||||
.claude/
|
||||
ai_notes.md
|
||||
ai_status.md
|
||||
backend/add_vendor_to_documents.js
|
||||
backend/fix_multivendor_constraint.js
|
||||
backend/server.js-backup
|
||||
|
||||
290
DESIGN_SYSTEM.md
Normal file
290
DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# CVE Intelligence Dashboard - Design System Reference
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
```css
|
||||
--intel-darkest: #0F172A /* Slate 900 - Deepest background */
|
||||
--intel-dark: #1E293B /* Slate 800 - Card backgrounds */
|
||||
--intel-medium: #334155 /* Slate 700 - Elevated elements */
|
||||
```
|
||||
|
||||
### Accent & Status Colors
|
||||
```css
|
||||
--intel-accent: #0EA5E9 /* Sky Blue - Primary accent, links, interactive elements */
|
||||
--intel-warning: #F59E0B /* Amber - Warnings, high severity, open tickets */
|
||||
--intel-danger: #EF4444 /* Red - Critical severity, destructive actions */
|
||||
--intel-success: #10B981 /* Emerald - Success states, low severity, confirmations */
|
||||
```
|
||||
|
||||
### Text Colors
|
||||
```css
|
||||
--text-primary: #F8FAFC /* Slate 50 - Primary text */
|
||||
--text-secondary: #E2E8F0 /* Slate 200 - Secondary text */
|
||||
--text-tertiary: #CBD5E1 /* Slate 300 - Labels, metadata */
|
||||
--text-muted: #94A3B8 /* Slate 400 - Placeholders, disabled */
|
||||
```
|
||||
|
||||
### Severity Badge Colors
|
||||
| Severity | Border | Background | Text | Glow Dot |
|
||||
|----------|--------|------------|------|----------|
|
||||
| **Critical** | `#EF4444` | `rgba(239, 68, 68, 0.25)` | `#FCA5A5` | `#EF4444` |
|
||||
| **High** | `#F59E0B` | `rgba(245, 158, 11, 0.25)` | `#FCD34D` | `#F59E0B` |
|
||||
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
||||
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
||||
|
||||
## Layout Structure
|
||||
|
||||
### Three-Column Grid Layout
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HEADER & STATS BAR │
|
||||
│ CVE INTEL | [Stats: Total, Entries, Tickets, Critical] │
|
||||
├──────────────┬─────────────────────────┬────────────────────┤
|
||||
│ │ │ │
|
||||
│ LEFT PANEL │ CENTER PANEL │ RIGHT PANEL │
|
||||
│ (3 cols) │ (6 cols) │ (3 cols) │
|
||||
│ │ │ │
|
||||
│ Knowledge │ Quick CVE Lookup │ Calendar │
|
||||
│ Base │ Search & Filters │ Widget │
|
||||
│ - Wiki │ CVE Results List │ │
|
||||
│ - Docs │ - Expandable cards │ Open Tickets │
|
||||
│ - Policies │ - Vendor entries │ - Compact list │
|
||||
│ - Guides │ - Documents │ - Quick stats │
|
||||
│ │ - JIRA tickets │ │
|
||||
│ │ │ │
|
||||
└──────────────┴─────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### Responsive Breakpoints
|
||||
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
||||
- **Tablet/Mobile**: Stacked single column
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### Stat Cards
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Border: 2px solid [accent-color]
|
||||
Border Radius: 0.5rem
|
||||
Padding: 1rem
|
||||
Top Accent Line: 2px gradient, 0 0 8px glow
|
||||
Shadow: 0 4px 16px rgba(0, 0, 0, 0.5)
|
||||
Hover: translateY(-2px), enhanced shadow
|
||||
```
|
||||
|
||||
### Intel Cards (Main Content)
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Border: 2px solid rgba(14, 165, 233, 0.4)
|
||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6), subtle glow
|
||||
Hover: Enhanced border (0.5 opacity), lift effect
|
||||
```
|
||||
|
||||
### Buttons
|
||||
```css
|
||||
/* Primary */
|
||||
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))
|
||||
Border: 1px solid #0EA5E9
|
||||
Color: #38BDF8
|
||||
Text Shadow: 0 0 6px rgba(14, 165, 233, 0.2)
|
||||
|
||||
/* Hover State */
|
||||
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.25), rgba(14, 165, 233, 0.2))
|
||||
Shadow: 0 0 20px rgba(14, 165, 233, 0.25)
|
||||
Transform: translateY(-1px)
|
||||
Ripple Effect: 300px radial on click
|
||||
```
|
||||
|
||||
### Input Fields
|
||||
```css
|
||||
Background: rgba(30, 41, 59, 0.6)
|
||||
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||
Font: 'JetBrains Mono', monospace
|
||||
Focus: border #0EA5E9, ring 2px rgba(14, 165, 233, 0.15)
|
||||
```
|
||||
|
||||
### Badges (Status/Severity)
|
||||
```css
|
||||
Display: inline-flex
|
||||
Align Items: center
|
||||
Gap: 0.5rem
|
||||
Border: 2px solid [severity-color]
|
||||
Border Radius: 0.375rem
|
||||
Padding: 0.375rem 0.875rem
|
||||
Font: 'JetBrains Mono', 0.75rem, 700, uppercase
|
||||
Letter Spacing: 0.5px
|
||||
Glow Dot: 8px circle with pulse animation
|
||||
```
|
||||
|
||||
## Interactions & Animations
|
||||
|
||||
### Hover Effects
|
||||
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
||||
- **Buttons**: Radial ripple expand (300px), slight lift
|
||||
- **List Items**: Border color shift, background lighten
|
||||
|
||||
### Animations
|
||||
```css
|
||||
/* Pulse Glow (for dots) */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
/* Scan Line */
|
||||
@keyframes scan {
|
||||
0%, 100% { transform: translateY(-100%); opacity: 0; }
|
||||
50% { transform: translateY(2000%); opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Spin (loading) */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### Transitions
|
||||
```css
|
||||
Standard: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
Fast: all 0.2s ease
|
||||
Ripple: width/height 0.5s
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Families
|
||||
```css
|
||||
Primary (UI): 'Outfit', system-ui, sans-serif
|
||||
Monospace (Data/Code): 'JetBrains Mono', monospace
|
||||
```
|
||||
|
||||
### Font Sizes & Weights
|
||||
```css
|
||||
/* Headings */
|
||||
h1: 2.5rem (40px), 700, monospace
|
||||
h2: 1.125rem (18px), 600, uppercase, tracking-wider
|
||||
h3: 1.125rem (18px), 600
|
||||
|
||||
/* Body */
|
||||
Body: 0.875rem (14px), 400
|
||||
Small: 0.75rem (12px), 400
|
||||
Labels: 0.75rem (12px), 500, uppercase, tracking-wider
|
||||
```
|
||||
|
||||
### Text Shadows (Headings)
|
||||
```css
|
||||
Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0.15)
|
||||
Badge Text: 0 0 8px rgba([color], 0.5)
|
||||
```
|
||||
|
||||
## Visual Effects
|
||||
|
||||
### Shadows
|
||||
```css
|
||||
/* Card Elevations */
|
||||
Level 1: 0 2px 6px rgba(0, 0, 0, 0.3)
|
||||
Level 2: 0 4px 12px rgba(0, 0, 0, 0.4)
|
||||
Level 3: 0 8px 24px rgba(0, 0, 0, 0.6)
|
||||
|
||||
/* Glows */
|
||||
Subtle: 0 0 12px rgba([color], 0.12)
|
||||
Medium: 0 0 20px rgba([color], 0.15)
|
||||
Strong: 0 0 28px rgba([color], 0.25)
|
||||
|
||||
/* Inset Highlights */
|
||||
Top: inset 0 1px 0 rgba(14, 165, 233, 0.15)
|
||||
Recessed: inset 0 2px 4px rgba(0, 0, 0, 0.3)
|
||||
```
|
||||
|
||||
### Border Styles
|
||||
```css
|
||||
/* Standard Cards */
|
||||
Border: 1.5-2px solid rgba(14, 165, 233, 0.3-0.4)
|
||||
|
||||
/* Accent Panels */
|
||||
Left Border: 3px solid [accent-color]
|
||||
|
||||
/* Vendor/Nested Cards */
|
||||
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||
```
|
||||
|
||||
### Gradients
|
||||
```css
|
||||
/* Backgrounds */
|
||||
Card: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Nested: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9))
|
||||
|
||||
/* Accent Lines */
|
||||
Top Bar: linear-gradient(90deg, transparent, [color], transparent)
|
||||
|
||||
/* Grid Background */
|
||||
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
|
||||
Size: 20px × 20px
|
||||
```
|
||||
|
||||
## Specific Component Patterns
|
||||
|
||||
### Wiki/Knowledge Base Entry
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||
Border: 1px solid rgba(16, 185, 129, 0.25)
|
||||
Padding: 0.75rem
|
||||
Cursor: pointer
|
||||
Hover: border-color shift to rgba(16, 185, 129, 0.4)
|
||||
```
|
||||
|
||||
### Calendar Widget
|
||||
```css
|
||||
Day Cells:
|
||||
- Text: white, font-mono, 0.75rem
|
||||
- Hover: bg rgba(14, 165, 233, 0.2)
|
||||
- Current Day: bg rgba(14, 165, 233, 0.3), border 1px #0EA5E9
|
||||
- Other Month: text rgba(148, 163, 184, 0.5)
|
||||
```
|
||||
|
||||
### Ticket Cards (Compact)
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||
Border: 1px solid rgba(245, 158, 11, 0.25)
|
||||
Padding: 0.5rem
|
||||
Status Badge: Reduced size (0.65rem, 0.25rem padding)
|
||||
Glow Dot: 6px diameter
|
||||
```
|
||||
|
||||
### CVE Expandable Cards
|
||||
```css
|
||||
Header: Clickable, cursor pointer
|
||||
Collapsed: Show summary (severity, vendor count, doc count)
|
||||
Expanded: Full description, metadata, vendor entries
|
||||
Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
|
||||
Vendor Cards: Nested with reduced opacity borders
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Contrast Ratios
|
||||
- Primary text on dark: 18.5:1 (AAA)
|
||||
- Secondary text on dark: 12.3:1 (AAA)
|
||||
- Accent colors: All meet WCAG AA minimum
|
||||
|
||||
### Interactive States
|
||||
- Focus rings: 2px solid accent color
|
||||
- Hover: Visible border/background changes
|
||||
- Active: Transform feedback
|
||||
|
||||
### Typography
|
||||
- Minimum size: 12px (0.75rem)
|
||||
- Line height: 1.5 for body text
|
||||
- Letter spacing: Generous for uppercase labels
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
||||
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
||||
3. **Refined Depth**: Layers and elevation without harsh neon
|
||||
4. **Purposeful Color**: Accent colors convey meaning (status, severity)
|
||||
5. **Smooth Interactions**: Polished micro-interactions and transitions
|
||||
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
||||
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
||||
|
||||
7
Ivanti_config_template.ini
Normal file
7
Ivanti_config_template.ini
Normal file
@@ -0,0 +1,7 @@
|
||||
[platform]
|
||||
url = https://platform4.risksense.com
|
||||
api_ver = /api/v1
|
||||
# PROD 1550 | UAT 1551
|
||||
client_id = <pick 1550 or 1551>
|
||||
[secrets]
|
||||
api_key = <your API key here>
|
||||
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",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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• 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• Audit logging"
|
||||
}
|
||||
],
|
||||
"appState": {
|
||||
"gridSize": null,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -2,3 +2,16 @@
|
||||
PORT=3001
|
||||
API_HOST=localhost
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||
NVD_API_KEY=
|
||||
|
||||
# Ivanti / RiskSense API (platform4.risksense.com)
|
||||
# API key from your profile settings — does not expire like session cookies
|
||||
IVANTI_API_KEY=
|
||||
IVANTI_CLIENT_ID=1550
|
||||
IVANTI_FIRST_NAME=
|
||||
IVANTI_LAST_NAME=
|
||||
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||
IVANTI_SKIP_TLS=false
|
||||
|
||||
39
backend/migrate_jira_tickets.js
Normal file
39
backend/migrate_jira_tickets.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Migration: Add jira_tickets table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting JIRA tickets migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Create jira_tickets table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
ticket_key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ jira_tickets table created');
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
|
||||
|
||||
console.log('✓ Indexes created');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
50
backend/migrations/add_archer_tickets_table.js
Normal file
50
backend/migrations/add_archer_tickets_table.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Migration: Add archer_tickets table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Archer tickets migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Create archer_tickets table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exc_number TEXT NOT NULL UNIQUE,
|
||||
archer_url TEXT,
|
||||
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ archer_tickets table created');
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor)', (err) => {
|
||||
if (err) console.error('Error creating CVE index:', err);
|
||||
else console.log('✓ CVE index created');
|
||||
});
|
||||
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status)', (err) => {
|
||||
if (err) console.error('Error creating status index:', err);
|
||||
else console.log('✓ Status index created');
|
||||
});
|
||||
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number)', (err) => {
|
||||
if (err) console.error('Error creating EXC number index:', err);
|
||||
else console.log('✓ EXC number index created');
|
||||
});
|
||||
|
||||
console.log('✓ Indexes created');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
79
backend/migrations/add_card_workflow_type.js
Normal file
79
backend/migrations/add_card_workflow_type.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// Migration: Add CARD to workflow_type CHECK constraint on ivanti_todo_queue
|
||||
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_card_workflow_type migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||
if (err) console.error('PRAGMA error:', err);
|
||||
});
|
||||
|
||||
db.run('BEGIN TRANSACTION', (err) => {
|
||||
if (err) { console.error('BEGIN error:', err); return; }
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE ivanti_todo_queue_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
ip_address TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating new table:', err);
|
||||
else console.log('✓ ivanti_todo_queue_new created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'INSERT INTO ivanti_todo_queue_new SELECT * FROM ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error copying data:', err);
|
||||
else console.log('✓ Data copied');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||
if (err) console.error('Error dropping old table:', err);
|
||||
else console.log('✓ Old table dropped');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error renaming table:', err);
|
||||
else console.log('✓ Table renamed');
|
||||
}
|
||||
);
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ Index recreated');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('COMMIT', (err) => {
|
||||
if (err) console.error('COMMIT error:', err);
|
||||
else console.log('✓ Transaction committed');
|
||||
});
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON', () => {});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
108
backend/migrations/add_compliance_tables.js
Normal file
108
backend/migrations/add_compliance_tables.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// Migration: Add compliance_uploads, compliance_items, compliance_notes tables
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_compliance_tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Each xlsx upload — one row per file ingested
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
report_date TEXT,
|
||||
uploaded_by INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
new_count INTEGER DEFAULT 0,
|
||||
resolved_count INTEGER DEFAULT 0,
|
||||
recurring_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating compliance_uploads:', err);
|
||||
else console.log('✓ compliance_uploads created');
|
||||
});
|
||||
|
||||
// One row per non-compliant asset per metric per upload.
|
||||
// hostname + metric_id is the stable identity key used to link history and notes.
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
device_type TEXT,
|
||||
team TEXT,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT,
|
||||
category TEXT,
|
||||
extra_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
|
||||
first_seen_upload_id INTEGER,
|
||||
resolved_upload_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (upload_id) REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (first_seen_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (resolved_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating compliance_items:', err);
|
||||
else console.log('✓ compliance_items created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload
|
||||
ON compliance_items(upload_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating upload index:', err);
|
||||
else console.log('✓ idx_compliance_items_upload created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity
|
||||
ON compliance_items(hostname, metric_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating identity index:', err);
|
||||
else console.log('✓ idx_compliance_items_identity created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status
|
||||
ON compliance_items(team, status)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating team/status index:', err);
|
||||
else console.log('✓ idx_compliance_items_team_status created');
|
||||
});
|
||||
|
||||
// Notes keyed on (hostname, metric_id) — persists across uploads.
|
||||
// Each note is its own row so history is preserved.
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating compliance_notes:', err);
|
||||
else console.log('✓ compliance_notes created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity
|
||||
ON compliance_notes(hostname, metric_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating notes identity index:', err);
|
||||
else console.log('✓ idx_compliance_notes_identity created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Migration: Add ivanti_counts_history table
|
||||
//
|
||||
// Stores a snapshot of open/closed Ivanti finding counts on every sync.
|
||||
// Unlike ivanti_counts_cache (single-row, always overwritten), this table
|
||||
// accumulates all snapshots so time-series charts can be built from it.
|
||||
//
|
||||
// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows
|
||||
// to the last snapshot per calendar day using a ROW_NUMBER window function.
|
||||
//
|
||||
// NOTE: This table is also created automatically at server startup via
|
||||
// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js).
|
||||
// This script is provided for manual setup on fresh installs and for
|
||||
// documentation consistency with other migration files.
|
||||
//
|
||||
// Usage: node backend/migrations/add_ivanti_counts_history_table.js
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting ivanti_counts_history migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_count INTEGER NOT NULL,
|
||||
closed_count INTEGER NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating ivanti_counts_history table:', err);
|
||||
else console.log('✓ ivanti_counts_history table created (or already exists)');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete.');
|
||||
});
|
||||
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Ivanti findings tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Cache table — single row holding the latest sync result
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating findings cache table:', err);
|
||||
else console.log('✓ ivanti_findings_cache table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) console.error('Error seeding findings cache row:', err);
|
||||
else console.log('✓ ivanti_findings_cache row seeded');
|
||||
});
|
||||
|
||||
// Notes table — one row per finding, persists across cache refreshes
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating finding notes table:', err);
|
||||
else console.log('✓ ivanti_finding_notes table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating notes index:', err);
|
||||
else console.log('✓ finding_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
37
backend/migrations/add_ivanti_sync_table.js
Normal file
37
backend/migrations/add_ivanti_sync_table.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Migration: Add ivanti_sync_state table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Ivanti sync state migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
workflows_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ ivanti_sync_state table created');
|
||||
});
|
||||
|
||||
// Seed the single-row state record
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) console.error('Error seeding state row:', err);
|
||||
else console.log('✓ ivanti_sync_state row seeded');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// Migration: Add ivanti_todo_queue table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting ivanti_todo_queue migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ ivanti_todo_queue table created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ User+status index created');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✓ Migration statements queued');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
70
backend/migrations/add_knowledge_base_table.js
Normal file
70
backend/migrations/add_knowledge_base_table.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Migration: Add knowledge_base table for storing documentation and policies
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Running migration: add_knowledge_base_table');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(100),
|
||||
file_path VARCHAR(500),
|
||||
file_name VARCHAR(255),
|
||||
file_type VARCHAR(50),
|
||||
file_size INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating knowledge_base table:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created knowledge_base table');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug
|
||||
ON knowledge_base(slug)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating slug index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on slug');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category
|
||||
ON knowledge_base(category)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating category index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on category');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at
|
||||
ON knowledge_base(created_at DESC)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating created_at index:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ Created index on created_at');
|
||||
console.log('\nMigration completed successfully!');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Migration: Add ip_address column to ivanti_todo_queue
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_todo_queue_ip_address migration...');
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue ADD COLUMN ip_address TEXT',
|
||||
(err) => {
|
||||
if (err) {
|
||||
// Column may already exist if migration was run before
|
||||
if (err.message.includes('duplicate column name')) {
|
||||
console.log('✓ ip_address column already exists, skipping');
|
||||
} else {
|
||||
console.error('Error adding column:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ ip_address column added');
|
||||
}
|
||||
db.close(() => console.log('Migration complete!'));
|
||||
}
|
||||
);
|
||||
242
backend/routes/archerTickets.js
Normal file
242
backend/routes/archerTickets.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// GET /status-trend — ticket counts grouped by creation date + status
|
||||
// Used for time-based Archer pipeline chart on the Compliance page.
|
||||
router.get('/status-trend', requireAuth(db), (req, res) => {
|
||||
db.all(
|
||||
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||
FROM archer_tickets
|
||||
GROUP BY DATE(created_at), status
|
||||
ORDER BY date ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching Archer status trend:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ statusTrend: rows });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createArcherTicketsRouter;
|
||||
@@ -219,8 +219,13 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up expired sessions (can be called periodically)
|
||||
// Clean up expired sessions (admin only)
|
||||
router.post('/cleanup-sessions', async (req, res) => {
|
||||
// Basic auth check - require a valid session to call this
|
||||
const sessionId = req.cookies?.session_id;
|
||||
if (!sessionId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
|
||||
712
backend/routes/compliance.js
Normal file
712
backend/routes/compliance.js
Normal file
@@ -0,0 +1,712 @@
|
||||
// Compliance Routes — AEO metric tracking
|
||||
// Handles xlsx upload/parse, non-compliant item history, and notes.
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /preview — parse xlsx, compute diff vs DB, return summary (no DB write)
|
||||
// POST /commit — commit a previewed upload to DB
|
||||
// GET /uploads — list all uploads
|
||||
// GET /summary — metric health cards for a team (from latest upload)
|
||||
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||
// POST /notes — add a note to a (hostname, metric_id) pair
|
||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ lastID: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row || null); });
|
||||
});
|
||||
}
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run Python parser, return parsed object
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseXlsx(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const py = spawn(PYTHON_BIN, [PARSER_SCRIPT, filePath]);
|
||||
let out = '';
|
||||
let err = '';
|
||||
py.stdout.on('data', d => { out += d; });
|
||||
py.stderr.on('data', d => { err += d; });
|
||||
py.on('close', code => {
|
||||
if (code !== 0) return reject(new Error(err || `Parser exited with code ${code}`));
|
||||
try { resolve(JSON.parse(out)); }
|
||||
catch (e) { reject(new Error('Parser returned invalid JSON')); }
|
||||
});
|
||||
py.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate that a temp file path is safely within uploads/temp/
|
||||
// ---------------------------------------------------------------------------
|
||||
function isSafeTempPath(filePath) {
|
||||
const resolved = path.resolve(filePath);
|
||||
return resolved.startsWith(TEMP_DIR + path.sep) && path.extname(resolved) === '.json';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compute diff: new / recurring / resolved
|
||||
// ---------------------------------------------------------------------------
|
||||
async function computeDiff(db, incomingItems) {
|
||||
const activeRows = await dbAll(db,
|
||||
`SELECT hostname, metric_id FROM compliance_items WHERE status = 'active'`
|
||||
);
|
||||
const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`));
|
||||
const newKeys = new Set(incomingItems.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||
|
||||
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||
for (const k of newKeys) { if (activeKeys.has(k)) recurringCount++; else newCount++; }
|
||||
for (const k of activeKeys) { if (!newKeys.has(k)) resolvedCount++; }
|
||||
|
||||
return { newCount, recurringCount, resolvedCount };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write a parsed upload to the DB (within a transaction)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function persistUpload(db, { items, summary, reportDate, filename, userId }) {
|
||||
// Pull current active items before we modify anything
|
||||
const activeRows = await dbAll(db,
|
||||
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'`
|
||||
);
|
||||
const activeMap = {};
|
||||
activeRows.forEach(r => { activeMap[`${r.hostname}|||${r.metric_id}`] = r; });
|
||||
|
||||
const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||
|
||||
await dbRun(db, 'BEGIN TRANSACTION');
|
||||
try {
|
||||
// 1. Insert the upload record
|
||||
const { lastID: uploadId } = await dbRun(db,
|
||||
`INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, summary_json)
|
||||
VALUES (?, ?, ?, datetime('now'), ?)`,
|
||||
[filename, reportDate || null, userId || null, JSON.stringify(summary)]
|
||||
);
|
||||
|
||||
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||
|
||||
// 2. Upsert each incoming non-compliant item
|
||||
for (const item of items) {
|
||||
const key = `${item.hostname}|||${item.metric_id}`;
|
||||
const existing = activeMap[key];
|
||||
const extraStr = JSON.stringify(item.extra_json || {});
|
||||
|
||||
if (existing) {
|
||||
// Recurring — bump seen_count, refresh snapshot fields
|
||||
await dbRun(db,
|
||||
`UPDATE compliance_items
|
||||
SET upload_id = ?, seen_count = ?, ip_address = ?, device_type = ?, extra_json = ?
|
||||
WHERE id = ?`,
|
||||
[uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr, existing.id]
|
||||
);
|
||||
recurringCount++;
|
||||
} else {
|
||||
// New item (or previously resolved and re-appearing)
|
||||
await dbRun(db,
|
||||
`INSERT INTO compliance_items
|
||||
(upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc,
|
||||
category, extra_json, status, first_seen_upload_id, seen_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1)`,
|
||||
[uploadId, item.hostname, item.ip_address, item.device_type, item.team,
|
||||
item.metric_id, item.metric_desc, item.category, extraStr, uploadId]
|
||||
);
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark items not present in this upload as resolved
|
||||
for (const [key, row] of Object.entries(activeMap)) {
|
||||
if (!newKeys.has(key)) {
|
||||
await dbRun(db,
|
||||
`UPDATE compliance_items
|
||||
SET status = 'resolved', resolved_upload_id = ?
|
||||
WHERE id = ?`,
|
||||
[uploadId, row.id]
|
||||
);
|
||||
resolvedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update upload with final counts
|
||||
await dbRun(db,
|
||||
`UPDATE compliance_uploads
|
||||
SET new_count = ?, resolved_count = ?, recurring_count = ?
|
||||
WHERE id = ?`,
|
||||
[newCount, resolvedCount, recurringCount, uploadId]
|
||||
);
|
||||
|
||||
await dbRun(db, 'COMMIT');
|
||||
return { uploadId, newCount, recurringCount, resolvedCount };
|
||||
|
||||
} catch (err) {
|
||||
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group flat compliance_items rows into per-device objects
|
||||
// ---------------------------------------------------------------------------
|
||||
function groupByHostname(rows, noteHostnames) {
|
||||
const deviceMap = {};
|
||||
|
||||
for (const row of rows) {
|
||||
if (!deviceMap[row.hostname]) {
|
||||
deviceMap[row.hostname] = {
|
||||
hostname: row.hostname,
|
||||
ip_address: row.ip_address || '',
|
||||
device_type: row.device_type || '',
|
||||
team: row.team || '',
|
||||
status: row.status,
|
||||
failing_metrics: [],
|
||||
seen_count: row.seen_count || 1,
|
||||
first_seen: row.first_seen || null,
|
||||
last_seen: row.last_seen || null,
|
||||
resolved_on: row.resolved_on || null,
|
||||
has_notes: noteHostnames.has(row.hostname),
|
||||
};
|
||||
}
|
||||
|
||||
const dev = deviceMap[row.hostname];
|
||||
dev.failing_metrics.push({
|
||||
metric_id: row.metric_id,
|
||||
metric_desc: row.metric_desc || '',
|
||||
category: row.category || '',
|
||||
});
|
||||
// Use the highest seen_count and earliest first_seen across all metrics
|
||||
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen))
|
||||
dev.first_seen = row.first_seen;
|
||||
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen))
|
||||
dev.last_seen = row.last_seen;
|
||||
}
|
||||
|
||||
return Object.values(deviceMap);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
const router = express.Router();
|
||||
|
||||
// Idempotent column additions — errors mean column already exists, which is fine
|
||||
db.run(`ALTER TABLE compliance_items ADD COLUMN seen_count INTEGER DEFAULT 1`, () => {});
|
||||
db.run(`ALTER TABLE compliance_uploads ADD COLUMN summary_json TEXT`, () => {});
|
||||
|
||||
// All compliance routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /preview
|
||||
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
|
||||
// Returns diff counts + tempFile path for the commit step.
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/preview', requireRole('editor', 'admin'), (req, res) => {
|
||||
upload.single('file')(req, res, async (uploadErr) => {
|
||||
if (uploadErr) {
|
||||
return res.status(400).json({ error: uploadErr.message });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
if (path.extname(req.file.originalname).toLowerCase() !== '.xlsx') {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(400).json({ error: 'File must be an .xlsx spreadsheet' });
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseXlsx(req.file.path);
|
||||
|
||||
if (parsed.error) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
return res.status(422).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
const diff = await computeDiff(db, parsed.items);
|
||||
|
||||
// Save parsed data to temp JSON — the commit step reads this
|
||||
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
const tempFilename = `compliance_preview_${Date.now()}_${Math.random().toString(36).slice(2)}.json`;
|
||||
const tempFilePath = path.join(TEMP_DIR, tempFilename);
|
||||
|
||||
fs.writeFileSync(tempFilePath, JSON.stringify({
|
||||
items: parsed.items,
|
||||
summary: parsed.summary,
|
||||
report_date: parsed.report_date,
|
||||
filename: req.file.originalname,
|
||||
}));
|
||||
|
||||
// Delete the original xlsx from temp (we only need the JSON now)
|
||||
fs.unlink(req.file.path, () => {});
|
||||
|
||||
res.json({
|
||||
diff: {
|
||||
new_count: diff.newCount,
|
||||
recurring_count: diff.recurringCount,
|
||||
resolved_count: diff.resolvedCount,
|
||||
},
|
||||
tempFile: tempFilePath,
|
||||
filename: req.file.originalname,
|
||||
report_date: parsed.report_date,
|
||||
total_items: parsed.total,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
console.error('[Compliance] Preview error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to parse file: ' + err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /commit
|
||||
// Commit a previewed upload to the DB.
|
||||
// Body: { tempFile, filename, report_date }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/commit', requireRole('editor', 'admin'), async (req, res) => {
|
||||
const { tempFile, filename, report_date } = req.body;
|
||||
|
||||
if (!tempFile || typeof tempFile !== 'string') {
|
||||
return res.status(400).json({ error: 'tempFile is required' });
|
||||
}
|
||||
if (!isSafeTempPath(tempFile)) {
|
||||
return res.status(400).json({ error: 'Invalid tempFile path' });
|
||||
}
|
||||
if (!fs.existsSync(tempFile)) {
|
||||
return res.status(400).json({ error: 'Preview session expired — please upload again' });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8'));
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Could not read preview data — please upload again' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await persistUpload(db, {
|
||||
items: parsed.items,
|
||||
summary: parsed.summary,
|
||||
reportDate: report_date || parsed.report_date,
|
||||
filename: filename || parsed.filename,
|
||||
userId: req.user?.id || null,
|
||||
});
|
||||
|
||||
fs.unlink(tempFile, () => {});
|
||||
|
||||
const upload = await dbGet(db,
|
||||
`SELECT id, filename, report_date, uploaded_at,
|
||||
new_count, resolved_count, recurring_count
|
||||
FROM compliance_uploads WHERE id = ?`,
|
||||
[result.uploadId]
|
||||
);
|
||||
|
||||
res.json({ upload });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] Commit error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to commit upload: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /uploads
|
||||
// List all uploads, most recent first.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/uploads', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT id, filename, report_date, uploaded_at,
|
||||
new_count, resolved_count, recurring_count
|
||||
FROM compliance_uploads
|
||||
ORDER BY id DESC`
|
||||
);
|
||||
res.json({ uploads: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /uploads error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /summary?team=STEAM
|
||||
// Return metric health rows for a team from the latest upload's summary_json.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/summary', async (req, res) => {
|
||||
const team = req.query.team;
|
||||
if (team && !ALLOWED_TEAMS.has(team)) {
|
||||
return res.status(400).json({ error: 'Invalid team' });
|
||||
}
|
||||
|
||||
try {
|
||||
const latestUpload = await dbGet(db,
|
||||
`SELECT id, summary_json, report_date, uploaded_at
|
||||
FROM compliance_uploads ORDER BY id DESC LIMIT 1`
|
||||
);
|
||||
if (!latestUpload || !latestUpload.summary_json) {
|
||||
return res.json({ entries: [], overall_scores: {}, upload: null });
|
||||
}
|
||||
|
||||
let summary;
|
||||
try { summary = JSON.parse(latestUpload.summary_json); }
|
||||
catch { return res.json({ entries: [], overall_scores: {}, upload: null }); }
|
||||
|
||||
let entries = summary.entries || [];
|
||||
if (team) {
|
||||
entries = entries.filter(e => e.team === team);
|
||||
}
|
||||
|
||||
res.json({
|
||||
entries,
|
||||
overall_scores: summary.overall_scores || {},
|
||||
upload: {
|
||||
id: latestUpload.id,
|
||||
report_date: latestUpload.report_date,
|
||||
uploaded_at: latestUpload.uploaded_at,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /summary error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /items?team=STEAM&status=active
|
||||
// Return non-compliant devices grouped by hostname.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/items', async (req, res) => {
|
||||
const { team, status = 'active' } = req.query;
|
||||
|
||||
if (!team) return res.status(400).json({ error: 'team is required' });
|
||||
if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
|
||||
if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.hostname, ci.ip_address, ci.device_type, ci.team,
|
||||
ci.metric_id, ci.metric_desc, ci.category,
|
||||
ci.status, ci.seen_count,
|
||||
fu.report_date AS first_seen,
|
||||
lu.report_date AS last_seen,
|
||||
ru.report_date AS resolved_on
|
||||
FROM compliance_items ci
|
||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.team = ? AND ci.status = ?
|
||||
ORDER BY ci.hostname, ci.metric_id`,
|
||||
[team, status]
|
||||
);
|
||||
|
||||
// Fetch hostnames that have any notes (for the has_notes indicator)
|
||||
const noteRows = await dbAll(db,
|
||||
`SELECT DISTINCT hostname FROM compliance_notes`
|
||||
);
|
||||
const noteHostnames = new Set(noteRows.map(r => r.hostname));
|
||||
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
res.json({ devices, team, status });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /items error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /items/:hostname
|
||||
// Detail panel: all metric rows for this hostname + notes + upload history.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/items/:hostname', async (req, res) => {
|
||||
const hostname = req.params.hostname;
|
||||
if (!hostname || hostname.length > 300) {
|
||||
return res.status(400).json({ error: 'Invalid hostname' });
|
||||
}
|
||||
|
||||
try {
|
||||
// All metric rows for this hostname
|
||||
const metricRows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.metric_id, ci.metric_desc, ci.category, ci.status,
|
||||
ci.ip_address, ci.device_type, ci.team,
|
||||
ci.seen_count, ci.extra_json,
|
||||
fu.report_date AS first_seen,
|
||||
fu.uploaded_at AS first_seen_at,
|
||||
lu.report_date AS last_seen,
|
||||
lu.uploaded_at AS last_seen_at,
|
||||
ru.report_date AS resolved_on
|
||||
FROM compliance_items ci
|
||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.hostname = ?
|
||||
ORDER BY ci.status DESC, ci.metric_id`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (metricRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
// Parse extra_json on each row
|
||||
const metrics = metricRows.map(r => ({
|
||||
...r,
|
||||
extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(),
|
||||
extra_json: undefined,
|
||||
}));
|
||||
|
||||
// Notes (all metrics for this hostname, sorted newest first)
|
||||
const notes = await dbAll(db,
|
||||
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
|
||||
u.username AS created_by
|
||||
FROM compliance_notes cn
|
||||
LEFT JOIN users u ON cn.created_by = u.id
|
||||
WHERE cn.hostname = ?
|
||||
ORDER BY cn.created_at DESC`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
// Derive device identity from the first active row, else any row
|
||||
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
||||
|
||||
res.json({
|
||||
hostname,
|
||||
ip_address: identity.ip_address || '',
|
||||
device_type: identity.device_type || '',
|
||||
team: identity.team || '',
|
||||
metrics,
|
||||
notes,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /notes
|
||||
// Add a note to a (hostname, metric_id) pair.
|
||||
// Body: { hostname, metric_id, note }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/notes', async (req, res) => {
|
||||
const { hostname, metric_id, note } = req.body;
|
||||
|
||||
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {
|
||||
return res.status(400).json({ error: 'Invalid hostname' });
|
||||
}
|
||||
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||
}
|
||||
const noteText = String(note || '').trim().slice(0, 1000);
|
||||
if (!noteText) {
|
||||
return res.status(400).json({ error: 'Note cannot be empty' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { lastID } = await dbRun(db,
|
||||
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
[hostname, metric_id, noteText, req.user?.id || null]
|
||||
);
|
||||
|
||||
const created = await dbGet(db,
|
||||
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
|
||||
u.username AS created_by
|
||||
FROM compliance_notes cn
|
||||
LEFT JOIN users u ON cn.created_by = u.id
|
||||
WHERE cn.id = ?`,
|
||||
[lastID]
|
||||
);
|
||||
|
||||
res.status(201).json(created);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Compliance] POST /notes error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to save note' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /notes/:hostname/:metricId
|
||||
// Return all notes for a (hostname, metric_id) pair.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/notes/:hostname/:metricId', async (req, res) => {
|
||||
const { hostname, metricId } = req.params;
|
||||
|
||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metricId' });
|
||||
|
||||
try {
|
||||
const notes = await dbAll(db,
|
||||
`SELECT cn.id, cn.note, cn.created_at, u.username AS created_by
|
||||
FROM compliance_notes cn
|
||||
LEFT JOIN users u ON cn.created_by = u.id
|
||||
WHERE cn.hostname = ? AND cn.metric_id = ?
|
||||
ORDER BY cn.created_at DESC`,
|
||||
[hostname, metricId]
|
||||
);
|
||||
res.json({ notes });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /notes error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /trends
|
||||
// Per-upload active totals + per-team counts for time-series charts.
|
||||
// Returns rows ordered ascending by report_date.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/trends', async (req, res) => {
|
||||
try {
|
||||
const uploads = await dbAll(db,
|
||||
`SELECT id, report_date,
|
||||
COALESCE(new_count, 0) AS new_count,
|
||||
COALESCE(recurring_count, 0) AS recurring_count,
|
||||
COALESCE(resolved_count, 0) AS resolved_count,
|
||||
COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active
|
||||
FROM compliance_uploads
|
||||
ORDER BY report_date ASC`
|
||||
);
|
||||
|
||||
if (uploads.length === 0) return res.json({ trends: [] });
|
||||
|
||||
// Per-team active counts — items whose upload_id matches the upload
|
||||
// (recurring items have upload_id bumped each cycle, so this is accurate)
|
||||
const teamRows = await dbAll(db,
|
||||
`SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count
|
||||
FROM compliance_items ci
|
||||
WHERE ci.team IS NOT NULL
|
||||
GROUP BY ci.upload_id, ci.team`
|
||||
);
|
||||
|
||||
const teamMap = {};
|
||||
teamRows.forEach(r => {
|
||||
if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {};
|
||||
teamMap[r.upload_id][r.team] = r.count;
|
||||
});
|
||||
|
||||
const trends = uploads.map(u => ({
|
||||
report_date: u.report_date,
|
||||
new_count: u.new_count,
|
||||
recurring_count: u.recurring_count,
|
||||
resolved_count: u.resolved_count,
|
||||
total_active: u.total_active,
|
||||
STEAM: teamMap[u.id]?.STEAM || 0,
|
||||
'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0,
|
||||
'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0,
|
||||
INTELDEV: teamMap[u.id]?.INTELDEV || 0,
|
||||
}));
|
||||
|
||||
res.json({ trends });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /trends error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /mttr
|
||||
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/mttr', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.team,
|
||||
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||
COUNT(*) AS resolved_count
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.resolved_upload_id IS NOT NULL
|
||||
AND fu.report_date IS NOT NULL
|
||||
AND ru.report_date IS NOT NULL
|
||||
GROUP BY ci.team
|
||||
ORDER BY avg_days DESC`
|
||||
);
|
||||
res.json({ mttr: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /mttr error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /top-recurring
|
||||
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||
// Identifies chronic compliance gaps that keep reappearing.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/top-recurring', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY team, metric_id
|
||||
ORDER BY seen_count DESC, host_count DESC
|
||||
LIMIT 20`
|
||||
);
|
||||
res.json({ items: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /category-trend
|
||||
// Active item counts per category per upload, for stacked area chart.
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/category-trend', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count
|
||||
FROM compliance_uploads cu
|
||||
JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||
GROUP BY cu.id, category
|
||||
ORDER BY cu.report_date ASC`
|
||||
);
|
||||
res.json({ categoryTrend: rows });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /category-trend error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createComplianceRouter;
|
||||
702
backend/routes/ivantiFindings.js
Normal file
702
backend/routes/ivantiFindings.js
Normal file
@@ -0,0 +1,702 @@
|
||||
// Ivanti / RiskSense Host Findings Routes
|
||||
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
||||
// Notes are stored separately so they survive cache refreshes.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const FINDINGS_FILTERS = [
|
||||
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
exclusive: false,
|
||||
operator: 'RANGE',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: '8.5,9.9',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'Open',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
||||
const CLOSED_COUNT_FILTERS = [
|
||||
{
|
||||
field: 'assetCustomAttributes.1550_host_1.value',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
exclusive: false,
|
||||
operator: 'RANGE',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: '8.5,9.9',
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'generic_state',
|
||||
exclusive: false,
|
||||
operator: 'EXACT',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'Closed',
|
||||
caseSensitive: false
|
||||
}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': Buffer.byteLength(bodyStr)
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 20000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table init
|
||||
// ---------------------------------------------------------------------------
|
||||
function initTables(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
open_count INTEGER DEFAULT 0,
|
||||
closed_count INTEGER DEFAULT 0,
|
||||
synced_at DATETIME
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
// Idempotent column additions — errors mean the column already exists, which is fine
|
||||
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||
VALUES (1, 0, 0)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(finding_id, field)
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||
ON ivanti_finding_overrides(finding_id)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_count INTEGER NOT NULL,
|
||||
closed_count INTEGER NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract only the fields we need from a raw finding object
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractFinding(f) {
|
||||
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
||||
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
||||
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
||||
|
||||
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
||||
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
||||
|
||||
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||
|
||||
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
||||
// system workflows and not actionable for our purposes.
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []),
|
||||
...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []),
|
||||
...(wfDist.expiredWorkflows || []),
|
||||
...(wfDist.approvedWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
|
||||
// Priority: actionable > requested > reworked > rejected > expired > approved
|
||||
const fpEntry = fpBuckets[0] || null;
|
||||
|
||||
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
||||
const generatedNames = f.workflowGeneratedNames || [];
|
||||
const fpFromNames = !fpEntry
|
||||
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||
: null;
|
||||
|
||||
const workflow = fpEntry ? {
|
||||
id: fpEntry.generatedId || '',
|
||||
state: fpEntry.state || '',
|
||||
type: 'FP',
|
||||
} : fpFromNames ? {
|
||||
id: fpFromNames,
|
||||
state: '',
|
||||
type: 'FP',
|
||||
} : null;
|
||||
|
||||
return {
|
||||
id: String(f.id),
|
||||
title: f.title || '',
|
||||
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||
hostName: f.host?.hostName || '',
|
||||
ipAddress: f.host?.ipAddress || '',
|
||||
dns: f.dns || f.host?.fqdn || '',
|
||||
status: f.status || '',
|
||||
slaStatus: f.slaStatus || '',
|
||||
dueDate,
|
||||
lastFoundOn: f.lastFoundOn || '',
|
||||
buOwnership,
|
||||
cves,
|
||||
workflow
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
try {
|
||||
const body = {
|
||||
filters: CLOSED_COUNT_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page: 0,
|
||||
size: 1
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
// RiskSense returns total in page.totalElements or page.total
|
||||
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
|
||||
// Append a snapshot to history — every sync is stored; the history
|
||||
// endpoint aggregates to last-per-day at query time (Option B).
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||
// Still update open count so it stays in sync; leave closed_count as-is
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[openCount]
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract FP workflow id+state from a raw (un-extracted) finding
|
||||
// Returns { id, state } or null if no FP# workflow present.
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractFPWorkflow(f) {
|
||||
const wfDist = f.workflowDistribution || {};
|
||||
const fpBuckets = [
|
||||
...(wfDist.actionableWorkflows || []),
|
||||
...(wfDist.requestedWorkflows || []),
|
||||
...(wfDist.reworkedWorkflows || []),
|
||||
...(wfDist.rejectedWorkflows || []),
|
||||
...(wfDist.expiredWorkflows || []),
|
||||
...(wfDist.approvedWorkflows || []),
|
||||
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||
const fpEntry = fpBuckets[0] || null;
|
||||
if (!fpEntry) return null;
|
||||
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync FP stats across ALL findings (open + closed).
|
||||
//
|
||||
// Produces two separate counts:
|
||||
// findingCounts — number of *findings* per FP workflow state
|
||||
// idCounts — number of *unique FP# ticket IDs* per state
|
||||
// (one FP# can cover many findings; this chart counts tickets)
|
||||
//
|
||||
// Open findings come from the already-extracted allFindings array.
|
||||
// Closed findings are swept page-by-page to catch Approved FPs.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
||||
const findingCounts = {}; // state → # findings
|
||||
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
||||
|
||||
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
||||
openFindings.forEach(f => {
|
||||
if (!f.workflow) return;
|
||||
const state = f.workflow.state || 'Unknown';
|
||||
const id = f.workflow.id || '';
|
||||
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
||||
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
||||
});
|
||||
|
||||
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const body = {
|
||||
filters: CLOSED_COUNT_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
if (result.status !== 200) {
|
||||
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
||||
break;
|
||||
}
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
findings.forEach(f => {
|
||||
const wf = extractFPWorkflow(f);
|
||||
if (!wf) return;
|
||||
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
||||
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
||||
});
|
||||
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
||||
// Fall through — store whatever we have from open findings
|
||||
}
|
||||
|
||||
// Aggregate unique FP# IDs by state
|
||||
const idCounts = {};
|
||||
Object.values(fpIdMap).forEach(state => {
|
||||
idCounts[state] = (idCounts[state] || 0) + 1;
|
||||
});
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
||||
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
||||
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
||||
|
||||
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
||||
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncFindings(db) {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||
console.warn('[Ivanti Findings]', errMsg);
|
||||
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Ivanti Findings] Starting sync...');
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||
let allFindings = [];
|
||||
let page = 0;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const body = {
|
||||
filters: FINDINGS_FILTERS,
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||
page,
|
||||
size: 100
|
||||
};
|
||||
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
|
||||
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
||||
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
||||
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
||||
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
totalPages = data.page?.totalPages || 1;
|
||||
const findings = data._embedded?.hostFindings || [];
|
||||
allFindings = allFindings.concat(findings.map(extractFinding));
|
||||
|
||||
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||
page++;
|
||||
} while (page < totalPages);
|
||||
|
||||
await dbRun(db,
|
||||
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||
[allFindings.length, JSON.stringify(allFindings)]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
function scheduleSync(db) {
|
||||
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
||||
if (err || !row || !row.synced_at) {
|
||||
syncFindings(db);
|
||||
} else {
|
||||
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSince >= 24) {
|
||||
syncFindings(db);
|
||||
} else {
|
||||
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, (err) => { if (err) reject(err); else resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
function readState(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||
let findings = [];
|
||||
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readNotes(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
||||
resolve(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readCounts(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
resolve({
|
||||
open: row?.open_count ?? 0,
|
||||
closed: row?.closed_count ?? 0,
|
||||
synced_at: row?.synced_at ?? null,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
||||
function readOverrides(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => {
|
||||
if (!map[r.finding_id]) map[r.finding_id] = {};
|
||||
map[r.finding_id][r.field] = r.value;
|
||||
});
|
||||
resolve(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readStateWithNotes(db) {
|
||||
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
||||
state.findings = state.findings.map((f) => ({
|
||||
...f,
|
||||
note: notes[f.id] || '',
|
||||
overrides: overrides[f.id] || {},
|
||||
}));
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
function createIvantiFindingsRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
initTables(db)
|
||||
.then(() => scheduleSync(db))
|
||||
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// GET / — cached findings with notes merged in
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading findings' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger immediate sync, return fresh state
|
||||
router.post('/sync', async (req, res) => {
|
||||
await syncFindings(db);
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /counts — open vs closed totals for pie chart
|
||||
router.get('/counts', async (req, res) => {
|
||||
try {
|
||||
res.json(await readCounts(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading counts' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||
router.get('/counts/history', async (req, res) => {
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT date, open_count, closed_count FROM (
|
||||
SELECT DATE(recorded_at) AS date,
|
||||
open_count, closed_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY DATE(recorded_at)
|
||||
ORDER BY recorded_at DESC
|
||||
) AS rn
|
||||
FROM ivanti_counts_history
|
||||
) WHERE rn = 1
|
||||
ORDER BY date ASC`,
|
||||
[],
|
||||
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||
);
|
||||
});
|
||||
res.json({ history: rows });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||
router.get('/fp-workflow-counts', async (req, res) => {
|
||||
try {
|
||||
const row = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
||||
(err, row) => { if (err) reject(err); else resolve(row); }
|
||||
);
|
||||
});
|
||||
let findingCounts = {};
|
||||
let idCounts = {};
|
||||
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
||||
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
||||
res.json({
|
||||
findingCounts,
|
||||
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
||||
idCounts,
|
||||
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const { field, value } = req.body;
|
||||
|
||||
if (!OVERRIDE_ALLOWED.includes(field)) {
|
||||
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
||||
}
|
||||
|
||||
const val = String(value ?? '').trim();
|
||||
|
||||
if (val === '') {
|
||||
// Empty value = clear the override (revert to Ivanti)
|
||||
db.run(
|
||||
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
||||
[findingId, field],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
||||
res.json({ finding_id: findingId, field, value: null });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
db.run(
|
||||
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
||||
[findingId, field, val],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
||||
res.json({ finding_id: findingId, field, value: val });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||
router.put('/:findingId/note', (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const note = String(req.body.note || '').slice(0, 255);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
||||
[findingId, note],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
||||
res.json({ finding_id: findingId, note });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiFindingsRouter;
|
||||
214
backend/routes/ivantiTodoQueue.js
Normal file
214
backend/routes/ivantiTodoQueue.js
Normal file
@@ -0,0 +1,214 @@
|
||||
// routes/ivantiTodoQueue.js
|
||||
const express = require('express');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
||||
const VALID_STATUSES = ['pending', 'complete'];
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/ivanti/todo-queue
|
||||
// Fetch current user's queue items, ordered by vendor then created_at
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
db.all(
|
||||
`SELECT * FROM ivanti_todo_queue
|
||||
WHERE user_id = ?
|
||||
ORDER BY vendor ASC, created_at ASC`,
|
||||
[req.user.id],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching todo queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
// Parse cves_json back to array for each row
|
||||
const parsed = rows.map((r) => ({
|
||||
...r,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
res.json(parsed);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/ivanti/todo-queue
|
||||
// Add a finding to the queue
|
||||
router.post('/', requireAuth(db), (req, res) => {
|
||||
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
|
||||
|
||||
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'finding_id is required.' });
|
||||
}
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
|
||||
}
|
||||
// Vendor is required for FP and Archer, optional for CARD
|
||||
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
||||
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
||||
const title = finding_title && typeof finding_title === 'string'
|
||||
? finding_title.slice(0, 500)
|
||||
: null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error adding to queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||
[this.lastID],
|
||||
(err2, row) => {
|
||||
if (err2 || !row) {
|
||||
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||
}
|
||||
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// PUT /api/ivanti/todo-queue/:id
|
||||
// Update vendor, workflow_type, or status — scoped to current user
|
||||
router.put('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { vendor, workflow_type, status } = req.body;
|
||||
|
||||
if (vendor !== undefined && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
||||
}
|
||||
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||
}
|
||||
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (vendor !== undefined) {
|
||||
updates.push('vendor = ?');
|
||||
params.push(vendor.trim());
|
||||
}
|
||||
if (workflow_type !== undefined) {
|
||||
updates.push('workflow_type = ?');
|
||||
params.push(workflow_type);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(id, req.user.id);
|
||||
|
||||
db.run(
|
||||
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||
params,
|
||||
function (err2) {
|
||||
if (err2) {
|
||||
console.error(err2);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||
[id],
|
||||
(err3, row) => {
|
||||
if (err3 || !row) {
|
||||
return res.json({ message: 'Queue item updated.' });
|
||||
}
|
||||
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/completed
|
||||
// Bulk-delete all completed items for the current user
|
||||
// IMPORTANT: This route must be registered BEFORE DELETE /:id
|
||||
router.delete('/completed', requireAuth(db), (req, res) => {
|
||||
db.run(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
||||
[req.user.id],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error clearing completed queue items:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ message: 'Completed items cleared.', deleted: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/ivanti/todo-queue/:id
|
||||
// Delete a single item — scoped to current user
|
||||
router.delete('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
function (err2) {
|
||||
if (err2) {
|
||||
console.error(err2);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ message: 'Queue item deleted.' });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiTodoQueueRouter;
|
||||
274
backend/routes/ivantiWorkflows.js
Normal file
274
backend/routes/ivantiWorkflows.js
Normal file
@@ -0,0 +1,274 @@
|
||||
// Ivanti / RiskSense Workflow Routes
|
||||
// Data is cached in SQLite and refreshed on a daily schedule or on-demand.
|
||||
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
|
||||
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — uses Node's https module directly so we can toggle
|
||||
// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': Buffer.byteLength(bodyStr)
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 15000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure the sync state table exists (idempotent — safe to call on every start)
|
||||
// ---------------------------------------------------------------------------
|
||||
function initTable(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
workflows_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync — calls Ivanti API, stores result in SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncWorkflows(db) {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||
|
||||
if (!apiKey) {
|
||||
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||
console.warn('[Ivanti]', errMsg);
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[errMsg], resolve
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Ivanti] Syncing workflows...');
|
||||
|
||||
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||
const body = {
|
||||
filters: [
|
||||
{
|
||||
field: 'created_by_last_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: lastName,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
field: 'created_by_first_name',
|
||||
exclusive: false,
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: firstName,
|
||||
caseSensitive: false
|
||||
}
|
||||
],
|
||||
projection: 'internal',
|
||||
sort: [{ field: 'created', direction: 'DESC' }],
|
||||
page: 0,
|
||||
size: 50
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||
|
||||
if (result.status === 401) {
|
||||
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||
}
|
||||
if (result.status === 419) {
|
||||
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||
}
|
||||
if (result.status === 429) {
|
||||
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||
}
|
||||
if (result.status !== 200) {
|
||||
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
|
||||
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
|
||||
let total = 0;
|
||||
let workflows = [];
|
||||
|
||||
if (data.page && typeof data.page.totalElements === 'number') {
|
||||
total = data.page.totalElements;
|
||||
workflows = data._embedded?.workflowBatches
|
||||
|| data._embedded?.workflowBatch
|
||||
|| [];
|
||||
} else if (typeof data.total === 'number') {
|
||||
total = data.total;
|
||||
workflows = data.data || data.content || data.results || [];
|
||||
} else if (typeof data.totalElements === 'number') {
|
||||
total = data.totalElements;
|
||||
workflows = data.content || data.data || [];
|
||||
} else if (Array.isArray(data)) {
|
||||
workflows = data;
|
||||
total = data.length;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state
|
||||
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL
|
||||
WHERE id=1`,
|
||||
[total, JSON.stringify(workflows)],
|
||||
(err) => { if (err) reject(err); else resolve(); }
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti] Sync failed:', msg);
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[msg], resolve
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
||||
// ---------------------------------------------------------------------------
|
||||
function scheduleSync(db) {
|
||||
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => {
|
||||
if (err || !row || !row.synced_at) {
|
||||
syncWorkflows(db);
|
||||
} else {
|
||||
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSince >= 24) {
|
||||
syncWorkflows(db);
|
||||
} else {
|
||||
const hoursUntil = (24 - hoursSince).toFixed(1);
|
||||
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper — read current state from DB and return as JSON-ready object
|
||||
// ---------------------------------------------------------------------------
|
||||
function readState(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||
|
||||
let workflows = [];
|
||||
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
|
||||
resolve({
|
||||
total: row.total || 0,
|
||||
workflows,
|
||||
synced_at: row.synced_at,
|
||||
sync_status: row.sync_status,
|
||||
error_message: row.error_message
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// Init table and kick off scheduler (fire-and-forget on startup)
|
||||
initTable(db)
|
||||
.then(() => scheduleSync(db))
|
||||
.catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// GET / — return cached data (fast, no external call)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading sync state' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||
router.post('/sync', async (req, res) => {
|
||||
await syncWorkflows(db);
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiWorkflowsRouter;
|
||||
352
backend/routes/knowledgeBase.js
Normal file
352
backend/routes/knowledgeBase.js
Normal file
@@ -0,0 +1,352 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
function createKnowledgeBaseRouter(db, upload) {
|
||||
const router = express.Router();
|
||||
|
||||
// Helper to sanitize filename
|
||||
function sanitizePathSegment(segment) {
|
||||
if (!segment || typeof segment !== 'string') return '';
|
||||
return segment
|
||||
.replace(/\0/g, '')
|
||||
.replace(/\.\./g, '')
|
||||
.replace(/[\/\\]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Helper to generate slug from title
|
||||
function generateSlug(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 200);
|
||||
}
|
||||
|
||||
// Helper to validate file type
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.pdf', '.md', '.txt', '.doc', '.docx',
|
||||
'.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.html', '.htm', '.json', '.yaml', '.yml',
|
||||
'.png', '.jpg', '.jpeg', '.gif'
|
||||
]);
|
||||
|
||||
function isValidFileType(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
// POST /api/knowledge-base/upload - Upload new document
|
||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('[KB Upload] Multer error:', err);
|
||||
return res.status(400).json({ error: err.message || 'File upload failed' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
console.log('[KB Upload] Request received:', {
|
||||
hasFile: !!req.file,
|
||||
body: req.body,
|
||||
contentType: req.headers['content-type']
|
||||
});
|
||||
|
||||
const uploadedFile = req.file;
|
||||
const { title, description, category } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !title.trim()) {
|
||||
console.error('[KB Upload] Error: Title is missing');
|
||||
if (uploadedFile) fs.unlinkSync(uploadedFile.path);
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
if (!uploadedFile) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!isValidFileType(uploadedFile.originalname)) {
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
return res.status(400).json({ error: 'File type not allowed' });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||
const slug = generateSlug(title);
|
||||
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(kbDir)) {
|
||||
fs.mkdirSync(kbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${timestamp}_${sanitizedName}`;
|
||||
const filePath = path.join(kbDir, filename);
|
||||
|
||||
try {
|
||||
// Move uploaded file to permanent location
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
|
||||
// Check if slug already exists
|
||||
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||
if (err) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.error('Error checking slug:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
|
||||
// If slug exists, append timestamp to make it unique
|
||||
const finalSlug = row ? `${slug}-${timestamp}` : slug;
|
||||
|
||||
// Insert new knowledge base entry
|
||||
const insertSql = `
|
||||
INSERT INTO knowledge_base (
|
||||
title, slug, description, category, file_path, file_name,
|
||||
file_type, file_size, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(
|
||||
insertSql,
|
||||
[
|
||||
title.trim(),
|
||||
finalSlug,
|
||||
description || null,
|
||||
category || 'General',
|
||||
filePath,
|
||||
sanitizedName,
|
||||
uploadedFile.mimetype,
|
||||
uploadedFile.size,
|
||||
req.user.id
|
||||
],
|
||||
function (err) {
|
||||
if (err) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.error('Error inserting knowledge base entry:', err);
|
||||
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'CREATE_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
this.lastID,
|
||||
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: this.lastID,
|
||||
title: title.trim(),
|
||||
slug: finalSlug,
|
||||
category: category || 'General'
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up file on error
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
console.error('Error uploading knowledge base document:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base - List all articles
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const sql = `
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
ORDER BY kb.created_at DESC
|
||||
`;
|
||||
|
||||
db.all(sql, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching knowledge base articles:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch articles' });
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id - Get single article details
|
||||
router.get('/:id', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
WHERE kb.id = ?
|
||||
`;
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching article:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
|
||||
res.json(row);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/content - Get document content for display
|
||||
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(row.file_path)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'VIEW_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ filename: row.file_name }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
// Determine content type for inline display
|
||||
let contentType = row.file_type || 'application/octet-stream';
|
||||
|
||||
// For markdown files, send as plain text so frontend can parse it
|
||||
if (row.file_name.endsWith('.md')) {
|
||||
contentType = 'text/plain; charset=utf-8';
|
||||
} else if (row.file_name.endsWith('.txt')) {
|
||||
contentType = 'text/plain; charset=utf-8';
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
// Use inline instead of attachment to allow browser to display
|
||||
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
|
||||
// Allow iframe embedding from frontend origin
|
||||
res.removeHeader('X-Frame-Options');
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/knowledge-base/:id/download - Download document
|
||||
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(row.file_path)) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DOWNLOAD_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ filename: row.file_name }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/knowledge-base/:id - Delete article
|
||||
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching article for deletion:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting article:', err);
|
||||
return res.status(500).json({ error: 'Failed to delete article' });
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if (fs.existsSync(row.file_path)) {
|
||||
fs.unlinkSync(row.file_path);
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(
|
||||
db,
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
'DELETE_KB_ARTICLE',
|
||||
'knowledge_base',
|
||||
id,
|
||||
JSON.stringify({ title: row.title }),
|
||||
req.ip
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createKnowledgeBaseRouter;
|
||||
94
backend/routes/nvdLookup.js
Normal file
94
backend/routes/nvdLookup.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// NVD CVE Lookup Routes
|
||||
const express = require('express');
|
||||
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
|
||||
function createNvdLookupRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// Lookup CVE details from NVD API 2.0
|
||||
router.get('/lookup/:cveId', async (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
|
||||
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||
}
|
||||
|
||||
const url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(cveId)}`;
|
||||
const headers = {};
|
||||
if (process.env.NVD_API_KEY) {
|
||||
headers['apiKey'] = process.env.NVD_API_KEY;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return res.status(429).json({ error: 'NVD API rate limit exceeded. Try again later.' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({ error: `NVD API returned status ${response.status}.` });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
|
||||
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||
}
|
||||
|
||||
const vuln = data.vulnerabilities[0].cve;
|
||||
|
||||
// Extract English description
|
||||
const descriptionEntry = vuln.descriptions?.find(d => d.lang === 'en');
|
||||
const description = descriptionEntry ? descriptionEntry.value : '';
|
||||
|
||||
// Extract severity with cascade: CVSS v3.1 → v3.0 → v2.0
|
||||
let severity = null;
|
||||
const metrics = vuln.metrics || {};
|
||||
|
||||
if (metrics.cvssMetricV31 && metrics.cvssMetricV31.length > 0) {
|
||||
severity = metrics.cvssMetricV31[0].cvssData?.baseSeverity;
|
||||
} else if (metrics.cvssMetricV30 && metrics.cvssMetricV30.length > 0) {
|
||||
severity = metrics.cvssMetricV30[0].cvssData?.baseSeverity;
|
||||
} else if (metrics.cvssMetricV2 && metrics.cvssMetricV2.length > 0) {
|
||||
severity = metrics.cvssMetricV2[0].baseSeverity;
|
||||
}
|
||||
|
||||
// Map NVD severity strings to app levels
|
||||
const severityMap = {
|
||||
'CRITICAL': 'Critical',
|
||||
'HIGH': 'High',
|
||||
'MEDIUM': 'Medium',
|
||||
'LOW': 'Low'
|
||||
};
|
||||
severity = severity ? (severityMap[severity.toUpperCase()] || 'Medium') : 'Medium';
|
||||
|
||||
// Extract published date (YYYY-MM-DD)
|
||||
const publishedRaw = vuln.published;
|
||||
const published_date = publishedRaw ? publishedRaw.split('T')[0] : '';
|
||||
|
||||
res.json({ description, severity, published_date });
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
return res.status(504).json({ error: 'NVD API request timed out.' });
|
||||
}
|
||||
console.error('NVD lookup error:', err);
|
||||
res.status(502).json({ error: 'Failed to reach NVD API.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createNvdLookupRouter;
|
||||
182
backend/scripts/import_notes_from_csv.py
Normal file
182
backend/scripts/import_notes_from_csv.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_notes_from_csv.py
|
||||
------------------------
|
||||
Mass-import finding notes from a CSV file into the CVE dashboard database.
|
||||
|
||||
CSV format (header row required, column names are case-insensitive):
|
||||
ID,NOTES
|
||||
12345,EXC-5754
|
||||
67890,EXC-6001 - pending review
|
||||
|
||||
Usage:
|
||||
python3 import_notes_from_csv.py <csv_file> [--db <db_path>] [--dry-run]
|
||||
|
||||
Options:
|
||||
--db <path> Path to cve_database.db (default: ../cve_database.db)
|
||||
--dry-run Print what would change without touching the database
|
||||
"""
|
||||
|
||||
import csv
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
|
||||
NOTE_MAX_LEN = 255
|
||||
|
||||
DEFAULT_DB = os.path.join(os.path.dirname(__file__), '..', 'cve_database.db')
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description='Import finding notes from CSV into the dashboard DB.')
|
||||
p.add_argument('csv_file', help='Path to the CSV file (must have ID and NOTES columns)')
|
||||
p.add_argument('--db', default=DEFAULT_DB, help=f'Path to SQLite database (default: {DEFAULT_DB})')
|
||||
p.add_argument('--dry-run', action='store_true', help='Preview changes without writing to DB')
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def load_csv(path):
|
||||
"""Read CSV and return list of (finding_id, note) tuples."""
|
||||
rows = []
|
||||
with open(path, newline='', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Normalise header names to uppercase for case-insensitive matching
|
||||
if reader.fieldnames is None:
|
||||
print('ERROR: CSV file is empty or has no header row.')
|
||||
sys.exit(1)
|
||||
|
||||
normalised = {k.strip().upper(): k for k in reader.fieldnames}
|
||||
if 'ID' not in normalised or 'NOTES' not in normalised:
|
||||
print(f'ERROR: CSV must have "ID" and "NOTES" columns.')
|
||||
print(f' Found columns: {list(reader.fieldnames)}')
|
||||
sys.exit(1)
|
||||
|
||||
id_col = normalised['ID']
|
||||
notes_col = normalised['NOTES']
|
||||
|
||||
for i, row in enumerate(reader, start=2): # start=2 because row 1 is the header
|
||||
finding_id = row[id_col].strip()
|
||||
note = row[notes_col].strip()
|
||||
|
||||
if not finding_id:
|
||||
print(f' WARNING row {i}: empty ID — skipping')
|
||||
continue
|
||||
|
||||
if len(note) > NOTE_MAX_LEN:
|
||||
print(f' WARNING row {i} ({finding_id}): note is {len(note)} chars, '
|
||||
f'truncating to {NOTE_MAX_LEN}')
|
||||
note = note[:NOTE_MAX_LEN]
|
||||
|
||||
rows.append((finding_id, note))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def run(args):
|
||||
csv_path = os.path.abspath(args.csv_file)
|
||||
db_path = os.path.abspath(args.db)
|
||||
|
||||
# ------------------------------------------------------------------ checks
|
||||
if not os.path.exists(csv_path):
|
||||
print(f'ERROR: CSV file not found: {csv_path}')
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f'ERROR: Database not found: {db_path}')
|
||||
sys.exit(1)
|
||||
|
||||
print(f'CSV : {csv_path}')
|
||||
print(f'DB : {db_path}')
|
||||
if args.dry_run:
|
||||
print('MODE: DRY RUN — no changes will be written\n')
|
||||
else:
|
||||
print()
|
||||
|
||||
# ----------------------------------------------------------------- load CSV
|
||||
rows = load_csv(csv_path)
|
||||
if not rows:
|
||||
print('No valid rows found in CSV.')
|
||||
sys.exit(0)
|
||||
|
||||
print(f'Loaded {len(rows)} row(s) from CSV.\n')
|
||||
|
||||
# ---------------------------------------------------------------- open DB
|
||||
con = sqlite3.connect(db_path)
|
||||
con.row_factory = sqlite3.Row
|
||||
cur = con.cursor()
|
||||
|
||||
# Fetch all known finding IDs — only IDs present here will be processed
|
||||
import json
|
||||
cur.execute('SELECT findings_json FROM ivanti_findings_cache WHERE id = 1')
|
||||
cache_row = cur.fetchone()
|
||||
known_ids = set()
|
||||
if cache_row and cache_row['findings_json']:
|
||||
try:
|
||||
known_ids = {str(f['id']) for f in json.loads(cache_row['findings_json'])}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not known_ids:
|
||||
print('ERROR: No findings found in the database cache.')
|
||||
print(' Run a Sync from the dashboard first, then re-run this script.')
|
||||
con.close()
|
||||
sys.exit(1)
|
||||
|
||||
print(f'{len(known_ids)} active finding(s) in cache.\n')
|
||||
|
||||
# ----------------------------------------------------------------- process
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
|
||||
for finding_id, note in rows:
|
||||
str_id = str(finding_id)
|
||||
|
||||
if str_id not in known_ids:
|
||||
print(f' SKIP {str_id} — not in active findings (resolved or never synced)')
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Check if a note already exists
|
||||
cur.execute('SELECT note FROM ivanti_finding_notes WHERE finding_id = ?', (str_id,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing['note'] == note:
|
||||
print(f' SKIP {str_id} — note unchanged')
|
||||
skipped += 1
|
||||
continue
|
||||
action = 'UPDATE'
|
||||
updated += 1
|
||||
else:
|
||||
action = 'INSERT'
|
||||
inserted += 1
|
||||
|
||||
print(f' {action:6s} {str_id} → {note[:80]}{"…" if len(note) > 80 else ""}')
|
||||
|
||||
if not args.dry_run:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id) DO UPDATE
|
||||
SET note = excluded.note, updated_at = datetime('now')
|
||||
""",
|
||||
(str_id, note)
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------- summary
|
||||
print()
|
||||
if args.dry_run:
|
||||
print(f'DRY RUN complete — would insert {inserted}, update {updated}, skip {skipped}.')
|
||||
else:
|
||||
con.commit()
|
||||
print(f'Done — inserted {inserted}, updated {updated}, skipped {skipped} (unchanged).')
|
||||
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(parse_args())
|
||||
212
backend/scripts/parse_compliance_xlsx.py
Normal file
212
backend/scripts/parse_compliance_xlsx.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse NTS_AEO compliance xlsx file and write JSON to stdout.
|
||||
Usage: python3 parse_compliance_xlsx.py <path_to_xlsx>
|
||||
|
||||
Output:
|
||||
{
|
||||
"items": [...], # non-compliant asset rows
|
||||
"summary": { ... }, # metric health data from Summary sheet
|
||||
"report_date": "YYYY-MM-DD" | null,
|
||||
"total": int
|
||||
}
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
|
||||
METRIC_CATEGORIES = {
|
||||
'2.3.4i': 'Vulnerability Management',
|
||||
'2.3.6i': 'Vulnerability Management',
|
||||
'2.3.8i': 'Vulnerability Management',
|
||||
'5.2.4': 'Access & MFA',
|
||||
'5.2.5': 'Access & MFA',
|
||||
'5.2.6': 'Access & MFA',
|
||||
'5.3.4': 'Endpoint Protection',
|
||||
'5.5.2': 'End-of-Life OS',
|
||||
'5.5.4i': 'Vulnerability Management',
|
||||
'5.5.5': 'Decommissioned Assets',
|
||||
'5.8.1': 'Application Security',
|
||||
'7.1.1': 'Logging & Monitoring',
|
||||
'7.6.13': 'Disaster Recovery',
|
||||
'7.6.16': 'Disaster Recovery',
|
||||
'Missing_AppID': 'Asset Data Quality',
|
||||
'Missing_DF': 'Asset Data Quality',
|
||||
'Missing_OS': 'Asset Data Quality',
|
||||
}
|
||||
|
||||
# Columns that go into the main item fields — everything else becomes extra_json
|
||||
CORE_COLS = {
|
||||
'Preferred - Hostname', 'GRANITE - IPv4_Address', 'GRANITE - Type',
|
||||
'Team', 'Compliant', 'Source_Network', 'Vertical',
|
||||
'GRANITE - Equip_Inst_ID', 'GRANITE - RESPONSIBLE_TEAM',
|
||||
}
|
||||
|
||||
SKIP_SHEETS = {'Summary', 'CMDB_9box'}
|
||||
|
||||
|
||||
def safe_str(val):
|
||||
s = str(val).strip()
|
||||
return '' if s == 'nan' else s
|
||||
|
||||
|
||||
def parse_summary(xl):
|
||||
"""Return { entries: [...], overall_scores: { customer_network, vertical } }"""
|
||||
df_raw = pd.read_excel(xl, sheet_name='Summary', header=None)
|
||||
|
||||
overall_scores = {
|
||||
'customer_network': float(df_raw.iloc[0, 4]) if pd.notna(df_raw.iloc[0, 4]) else None,
|
||||
'vertical': float(df_raw.iloc[1, 4]) if pd.notna(df_raw.iloc[1, 4]) else None,
|
||||
}
|
||||
|
||||
df = pd.read_excel(xl, sheet_name='Summary', header=3)
|
||||
# Flatten any newlines in column names
|
||||
df.columns = [str(c).replace('\n', ' ').strip() for c in df.columns]
|
||||
|
||||
# Locate the sub-vertical/team column robustly
|
||||
team_col = next((c for c in df.columns if 'Sub-Vertical' in c or 'Purchase Group' in c), None)
|
||||
|
||||
entries = []
|
||||
for _, row in df.iterrows():
|
||||
metric_id = safe_str(row.get('Metric', ''))
|
||||
if not metric_id or metric_id in ('Metric',):
|
||||
continue
|
||||
|
||||
team = safe_str(row.get(team_col, '')) if team_col else ''
|
||||
|
||||
try:
|
||||
non_compliant = int(row.get('Non-Compliant', 0) or 0)
|
||||
compliant = int(row.get('Compliant', 0) or 0)
|
||||
total = int(row.get('Total', 0) or 0)
|
||||
compliance_pct = float(row.get('Current Compliance', 0) or 0)
|
||||
target = float(row.get('Metric Target', 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
entries.append({
|
||||
'metric_id': metric_id,
|
||||
'team': team,
|
||||
'priority': safe_str(row.get('Priority / Non-Priority / IR', '')),
|
||||
'non_compliant': non_compliant,
|
||||
'compliant': compliant,
|
||||
'total': total,
|
||||
'compliance_pct': compliance_pct,
|
||||
'target': target,
|
||||
'status': safe_str(row.get('Status', '')),
|
||||
'description': safe_str(row.get('Metric Description', '')),
|
||||
'category': METRIC_CATEGORIES.get(metric_id, 'Other'),
|
||||
})
|
||||
|
||||
return {'entries': entries, 'overall_scores': overall_scores}
|
||||
|
||||
|
||||
def parse_sheet(xl, sheet_name, summary_entries):
|
||||
"""Return list of non-compliant item dicts for a detail sheet."""
|
||||
try:
|
||||
df = pd.read_excel(xl, sheet_name=sheet_name, header=0)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
df.columns = [str(c).strip() for c in df.columns]
|
||||
|
||||
# Filter to non-compliant rows when the Compliant column exists
|
||||
if 'Compliant' in df.columns:
|
||||
df = df[df['Compliant'] == False]
|
||||
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Look up description from summary
|
||||
metric_desc = ''
|
||||
for e in summary_entries:
|
||||
if e['metric_id'] == sheet_name and e['description']:
|
||||
metric_desc = e['description']
|
||||
break
|
||||
|
||||
category = METRIC_CATEGORIES.get(sheet_name, 'Other')
|
||||
|
||||
items = []
|
||||
for _, row in df.iterrows():
|
||||
hostname = safe_str(row.get('Preferred - Hostname', ''))
|
||||
if not hostname:
|
||||
continue
|
||||
|
||||
ip = safe_str(row.get('GRANITE - IPv4_Address', ''))
|
||||
device_type = safe_str(row.get('GRANITE - Type', ''))
|
||||
team = safe_str(row.get('Team', ''))
|
||||
|
||||
# Everything non-core goes into extra_json
|
||||
extra = {}
|
||||
for col in df.columns:
|
||||
if col in CORE_COLS:
|
||||
continue
|
||||
val = row.get(col)
|
||||
if pd.isna(val) if not isinstance(val, str) else False:
|
||||
continue
|
||||
s = safe_str(val)
|
||||
if s:
|
||||
extra[col] = val.isoformat() if hasattr(val, 'isoformat') else s
|
||||
|
||||
items.append({
|
||||
'hostname': hostname,
|
||||
'ip_address': ip,
|
||||
'device_type': device_type,
|
||||
'team': team,
|
||||
'metric_id': sheet_name,
|
||||
'metric_desc': metric_desc,
|
||||
'category': category,
|
||||
'extra_json': extra,
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def extract_report_date(filepath):
|
||||
"""Try to pull YYYY-MM-DD from the filename, e.g. NTS_AEO_2026_03_25.xlsx"""
|
||||
stem = Path(filepath).stem
|
||||
m = re.search(r'(\d{4})_(\d{2})_(\d{2})', stem)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({'error': 'No file path provided'}))
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
|
||||
try:
|
||||
xl = pd.ExcelFile(filepath)
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': f'Cannot open file: {str(e)}'}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
summary = parse_summary(xl)
|
||||
except Exception as e:
|
||||
summary = {'entries': [], 'overall_scores': {}, 'parse_error': str(e)}
|
||||
|
||||
all_items = []
|
||||
for sheet_name in xl.sheet_names:
|
||||
if sheet_name in SKIP_SHEETS:
|
||||
continue
|
||||
items = parse_sheet(xl, sheet_name, summary.get('entries', []))
|
||||
all_items.extend(items)
|
||||
|
||||
print(json.dumps({
|
||||
'items': all_items,
|
||||
'summary': summary,
|
||||
'report_date': extract_report_date(filepath),
|
||||
'total': len(all_items),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
120
docs/MOP-workflow-color-codes.md
Normal file
120
docs/MOP-workflow-color-codes.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
|
||||
|
||||
**Document Type:** Method of Procedure
|
||||
**Applies To:** STEAM Security Dashboard — Reporting Page
|
||||
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Background
|
||||
|
||||
### What the Reporting Page Shows
|
||||
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
|
||||
|
||||
### What the Workflow Column Shows
|
||||
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
|
||||
|
||||
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
|
||||
|
||||
### Key Rule
|
||||
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
|
||||
|
||||
---
|
||||
|
||||
## 3. Workflow Column Color Codes
|
||||
|
||||
### 🔴 Red — Act Immediately
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||
| **Rejected** | The security team reviewed the FP request and denied it. The finding is considered a real, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Amber — Action Required Soon
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
|
||||
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
|
||||
|
||||
---
|
||||
|
||||
### 🔵 Blue — In Flight, Monitor
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
|
||||
|
||||
---
|
||||
|
||||
### — (No Badge) — Untriaged
|
||||
|
||||
| State | What It Means | Required Action |
|
||||
|---|---|---|
|
||||
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision Flowchart
|
||||
|
||||
```
|
||||
Finding appears in Reporting page
|
||||
│
|
||||
├── Does it have a Workflow badge?
|
||||
│ │
|
||||
│ ├── NO (—)
|
||||
│ │ └── Triage → Remediate OR submit new FP request
|
||||
│ │
|
||||
│ └── YES → Check the color:
|
||||
│ │
|
||||
│ ├── 🔵 BLUE (Requested)
|
||||
│ │ └── Wait for approval. Follow up if SLA window is approaching.
|
||||
│ │
|
||||
│ ├── 🟡 AMBER (Reworked / Actionable)
|
||||
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
|
||||
│ │
|
||||
│ └── 🔴 RED
|
||||
│ │
|
||||
│ ├── Expired → Submit NEW FP request in Ivanti
|
||||
│ │
|
||||
│ └── Rejected → Remediate the vulnerability
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. How to Submit or Renew an FP Request in Ivanti
|
||||
|
||||
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
|
||||
2. Navigate to **Host Findings**
|
||||
3. Search for the Finding ID shown in the dashboard (Finding ID column)
|
||||
4. Select the finding → **Actions** → **Request False Positive**
|
||||
5. Complete the justification form:
|
||||
- Describe why the finding is not exploitable in this environment
|
||||
- Reference any compensating controls, network segmentation, or vendor guidance
|
||||
- Attach supporting evidence if available
|
||||
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
|
||||
|
||||
---
|
||||
|
||||
## 6. Quick Reference Card
|
||||
|
||||
| Badge Color | State | One-Line Action |
|
||||
|---|---|---|
|
||||
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||
| 🟡 Amber | Actionable | Review ticket in Ivanti |
|
||||
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||
| — | No badge | Triage: remediate or submit FP |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-11*
|
||||
73
docs/python-venv-setup.md
Normal file
73
docs/python-venv-setup.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Python Dependencies — Compliance xlsx Parsing
|
||||
|
||||
`parse_compliance_xlsx.py` requires `pandas` and `openpyxl`. This doc
|
||||
explains how each server has (or should have) these installed.
|
||||
|
||||
---
|
||||
|
||||
## Dev server — how it works
|
||||
|
||||
Pandas and openpyxl are installed as **system apt packages**, not via pip
|
||||
or a venv. This is why there is no venv on dev and no `--break-system-packages`
|
||||
gymnastics. They were installed at some point via:
|
||||
|
||||
```bash
|
||||
apt install python3-pandas python3-openpyxl
|
||||
```
|
||||
|
||||
You can verify with:
|
||||
|
||||
```bash
|
||||
python3 -c "import pandas; print(pandas.__file__)"
|
||||
# /usr/lib/python3/dist-packages/pandas/__init__.py ← apt-managed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production server — how to fix it
|
||||
|
||||
Production was missing pandas entirely. The fix mirrors what dev has:
|
||||
|
||||
```bash
|
||||
apt-get update --fix-missing
|
||||
apt install -y python3-pandas python3-openpyxl
|
||||
```
|
||||
|
||||
No venv, no pip, no `PYTHON_BIN` env var needed. After installing, restart
|
||||
the backend and the compliance xlsx upload will work.
|
||||
|
||||
---
|
||||
|
||||
## If apt packages are unavailable (fallback)
|
||||
|
||||
If you're on a system where apt doesn't have pandas (unlikely on Ubuntu
|
||||
22.04/24.04), or you want isolation, use a venv:
|
||||
|
||||
```bash
|
||||
apt install -y python3-venv python3-full
|
||||
python3 -m venv /home/cve-dashboard/venv
|
||||
/home/cve-dashboard/venv/bin/pip install -r /home/cve-dashboard/backend/scripts/requirements.txt
|
||||
```
|
||||
|
||||
Then set `PYTHON_BIN` in the Node backend's environment:
|
||||
|
||||
```bash
|
||||
export PYTHON_BIN=/home/cve-dashboard/venv/bin/python3
|
||||
```
|
||||
|
||||
The backend reads `process.env.PYTHON_BIN` and falls back to `python3` if
|
||||
not set, so this only needs to be done if you're using a venv.
|
||||
|
||||
---
|
||||
|
||||
## Why pip3 may fail on modern Ubuntu/Debian
|
||||
|
||||
PEP 668 (enforced in Ubuntu 23.04+) blocks `pip3 install` system-wide to
|
||||
prevent breaking apt-managed packages. The error looks like:
|
||||
|
||||
```
|
||||
error: externally-managed-environment
|
||||
```
|
||||
|
||||
Using `apt install python3-pandas` is the correct solution — pip is not
|
||||
needed when the distro packages the library directly.
|
||||
183
docs/security-posture-workflow-diagrams.md
Normal file
183
docs/security-posture-workflow-diagrams.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Security Posture Workflow — Diagrams
|
||||
|
||||
Mermaid diagrams for the Host Finding Review & Remediation process.
|
||||
Renders natively in GitHub, GitLab, and most modern documentation tools.
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — Host Finding Review Workflow (Steps 1–5)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([Open Vulnerability Triage Page]) --> SYNC
|
||||
|
||||
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||
SYNC --> DUE{Overdue<br/>findings?}
|
||||
DUE -->|Yes — start here| HOST
|
||||
DUE -->|No — start with amber| HOST
|
||||
|
||||
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
|
||||
HOST --> CORRECT{Hostname<br/>correct?}
|
||||
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
|
||||
EDIT --> OWN
|
||||
CORRECT -->|Yes| OWN
|
||||
|
||||
OWN["③ Identify Asset Ownership<br/>Check BU column"]
|
||||
OWN --> BU{Our BU?}
|
||||
BU -->|"NTS-AEO-STEAM<br/>or ACCESS-ENG"| CVE
|
||||
BU -->|"Other BU<br/>or blank"| CARD["Add to CARD Queue<br/>☑ checkbox → CARD → Add to Queue"]
|
||||
CARD --> CARD2([Process in dedicated CARD session])
|
||||
|
||||
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover +N badge for more"]
|
||||
CVE --> DBCHECK{CVE in<br/>database?}
|
||||
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
|
||||
ADDCVE --> RESEARCH
|
||||
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
|
||||
|
||||
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
|
||||
RESEARCH --> ACTION
|
||||
|
||||
ACTION["⑤ Determine Required Action"]
|
||||
ACTION --> PATH{What does<br/>research show?}
|
||||
|
||||
PATH -->|"Patch available<br/>FW / SW update"| PA
|
||||
PATH -->|"Fix is config<br/>change only"| PB
|
||||
PATH -->|"Not applicable<br/>to platform / version"| PC
|
||||
PATH -->|"Cannot patch<br/>vendor / EOL / business"| PD
|
||||
|
||||
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
|
||||
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
|
||||
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
|
||||
|
||||
PB["PATH B — Remediation<br/>Configuration Change"]
|
||||
PB --> PB1["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
|
||||
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||
|
||||
PC["PATH C — False Positive"]
|
||||
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
|
||||
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
|
||||
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
|
||||
PC3 --> PC4["☑ checkbox → Vendor → FP<br/>Add to Queue"]
|
||||
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
|
||||
|
||||
PD["PATH D — Risk Acceptance"]
|
||||
PD --> PD1["Take device screenshot<br/>Collect version info"]
|
||||
PD1 --> PD2{Vendor comms<br/>needed?}
|
||||
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
|
||||
PD3 --> PD4
|
||||
PD2 -->|No| PD4["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
|
||||
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||
|
||||
%% Styling
|
||||
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
|
||||
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
|
||||
|
||||
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
|
||||
class DUE,CORRECT,BU,DBCHECK,PATH decision
|
||||
class PA,PA1,PA2 pathA
|
||||
class PB,PB1,PB2,PB3 pathB
|
||||
class PC,PC1,PC2,PC3,PC4,PC5 pathC
|
||||
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
|
||||
class CARD,CARD2 card
|
||||
class EDIT done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — FP Workflow Badge Status Decision Tree
|
||||
|
||||
What to do when a finding already has a workflow badge in the Vulnerability Triage page.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A([Finding in<br/>Vulnerability Triage]) --> B{"Check<br/>Workflow column"}
|
||||
|
||||
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||
C --> C1(["Follow the<br/>Step 1–5 triage workflow ↑"])
|
||||
|
||||
B -->|"🔵 Blue<br/>Requested"| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
|
||||
D --> D1{"SLA window<br/>approaching?"}
|
||||
D1 -->|No| D2(["Monitor — no action yet ✓"])
|
||||
D1 -->|Yes| D3(["Follow up with<br/>the approver"])
|
||||
|
||||
B -->|"🟡 Amber<br/>Reworked"| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
|
||||
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
|
||||
E1 --> E2(["Update justification<br/>and resubmit"])
|
||||
|
||||
B -->|"🟡 Amber<br/>Actionable"| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
|
||||
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
|
||||
|
||||
B -->|"🔴 Red<br/>Expired"| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
|
||||
G --> G1(["Submit a new FP request<br/>in Ivanti<br/>Reference previous ticket"])
|
||||
|
||||
B -->|"🔴 Red<br/>Rejected"| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
|
||||
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP<br/>without new evidence"])
|
||||
|
||||
%% Styling
|
||||
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
|
||||
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
|
||||
|
||||
class A,B trigger
|
||||
class D,D1,D2,D3 blue
|
||||
class E,E1,E2,F,F1 amber
|
||||
class G,G1,H,H1 red
|
||||
class C,C1 none
|
||||
class D2,D3,E2,F1,G1,H1 done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — Action Decision Matrix (Quick Reference)
|
||||
|
||||
Condensed view of the five research outcomes and their required actions.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
START(["Research complete<br/>Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
|
||||
|
||||
Q --> R1["Firmware or<br/>Software update available"]
|
||||
R1 --> A1(["No ticket needed<br/>Schedule upgrade<br/>Add note to finding"])
|
||||
|
||||
Q --> R2["Fix is a<br/>configuration change"]
|
||||
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
|
||||
|
||||
Q --> R3["Not applicable<br/>to this platform / version"]
|
||||
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
|
||||
|
||||
Q --> R4["Patch not yet<br/>available from vendor"]
|
||||
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
|
||||
|
||||
Q --> R5["Device is EOL / EOS<br/>or business constraint"]
|
||||
R5 --> A5(["Archer ticket with<br/>mitigation steps +<br/>remediation plan"])
|
||||
|
||||
Q --> R6["Asset not owned<br/>by our BU"]
|
||||
R6 --> A6(["CARD queue<br/>CARD disposition process"])
|
||||
|
||||
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
|
||||
|
||||
class START,Q q
|
||||
class R1,A1 green
|
||||
class R2,A2,R4,A4,R5,A5 amber
|
||||
class R3,A3 red
|
||||
class R6,A6 teal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Source document: `docs/security-posture-workflow.md`*
|
||||
175
docs/security-posture-workflow-lucidchart.md
Normal file
175
docs/security-posture-workflow-lucidchart.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Lucidchart Import — Raw Mermaid Code
|
||||
|
||||
Lucidchart expects raw Mermaid syntax only — no markdown headings or prose.
|
||||
Paste each diagram separately: Insert → Diagram as Code → Mermaid → paste → Generate.
|
||||
|
||||
---
|
||||
|
||||
## DIAGRAM 1 — Host Finding Review Workflow
|
||||
|
||||
Paste everything between the triple-backtick fences below:
|
||||
|
||||
```
|
||||
flowchart TD
|
||||
START([Open Reporting Page]) --> SYNC
|
||||
|
||||
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||
SYNC --> DUE{Overdue<br/>findings?}
|
||||
DUE -->|Yes — start here| HOST
|
||||
DUE -->|No — start with amber| HOST
|
||||
|
||||
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
|
||||
HOST --> CORRECT{Hostname<br/>correct?}
|
||||
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
|
||||
EDIT --> OWN
|
||||
CORRECT -->|Yes| OWN
|
||||
|
||||
OWN["③ Identify Asset Ownership<br/>Check BU column"]
|
||||
OWN --> BU{Our BU?}
|
||||
BU -->|"NTS-AEO-STEAM or ACCESS-ENG"| CVE
|
||||
BU -->|"Other BU or blank"| CARD["Add to CARD Queue<br/>checkbox → CARD → Add to Queue"]
|
||||
CARD --> CARD2([Process in dedicated CARD session])
|
||||
|
||||
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover badge for more"]
|
||||
CVE --> DBCHECK{CVE in<br/>database?}
|
||||
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
|
||||
ADDCVE --> RESEARCH
|
||||
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
|
||||
|
||||
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
|
||||
RESEARCH --> ACTION
|
||||
|
||||
ACTION["⑤ Determine Required Action"]
|
||||
ACTION --> PATH{What does<br/>research show?}
|
||||
|
||||
PATH -->|"Patch available — FW / SW update"| PA
|
||||
PATH -->|"Fix is config change only"| PB
|
||||
PATH -->|"Not applicable to platform / version"| PC
|
||||
PATH -->|"Cannot patch — vendor / EOL / business"| PD
|
||||
|
||||
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
|
||||
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
|
||||
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
|
||||
|
||||
PB["PATH B — Remediation<br/>Configuration Change"]
|
||||
PB --> PB1["checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
|
||||
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||
|
||||
PC["PATH C — False Positive"]
|
||||
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
|
||||
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
|
||||
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
|
||||
PC3 --> PC4["checkbox → Vendor → FP<br/>Add to Queue"]
|
||||
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
|
||||
|
||||
PD["PATH D — Risk Acceptance"]
|
||||
PD --> PD1["Take device screenshot<br/>Collect version info"]
|
||||
PD1 --> PD2{Vendor comms<br/>needed?}
|
||||
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
|
||||
PD3 --> PD4
|
||||
PD2 -->|No| PD4["checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
|
||||
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||
|
||||
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
|
||||
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
|
||||
|
||||
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
|
||||
class DUE,CORRECT,BU,DBCHECK,PATH decision
|
||||
class PA,PA1,PA2 pathA
|
||||
class PB,PB1,PB2,PB3 pathB
|
||||
class PC,PC1,PC2,PC3,PC4,PC5 pathC
|
||||
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
|
||||
class CARD,CARD2 card
|
||||
class EDIT done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DIAGRAM 2 — FP Workflow Badge Status Decision Tree
|
||||
|
||||
```
|
||||
flowchart LR
|
||||
A([Finding in Reporting Page]) --> B{"Check Workflow column"}
|
||||
|
||||
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||
C --> C1(["Follow the Step 1-5 triage workflow"])
|
||||
|
||||
B -->|Blue - Requested| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
|
||||
D --> D1{"SLA window<br/>approaching?"}
|
||||
D1 -->|No| D2(["Monitor — no action yet"])
|
||||
D1 -->|Yes| D3(["Follow up with the approver"])
|
||||
|
||||
B -->|Amber - Reworked| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
|
||||
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
|
||||
E1 --> E2(["Update justification and resubmit"])
|
||||
|
||||
B -->|Amber - Actionable| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
|
||||
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
|
||||
|
||||
B -->|Red - Expired| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
|
||||
G --> G1(["Submit a new FP request in Ivanti<br/>Reference previous ticket"])
|
||||
|
||||
B -->|Red - Rejected| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
|
||||
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP without new evidence"])
|
||||
|
||||
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
|
||||
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
|
||||
|
||||
class A,B trigger
|
||||
class D,D1,D2,D3 blue
|
||||
class E,E1,E2,F,F1 amber
|
||||
class G,G1,H,H1 red
|
||||
class C,C1 none
|
||||
class D2,D3,E2,F1,G1,H1 done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DIAGRAM 3 — Action Decision Matrix
|
||||
|
||||
```
|
||||
flowchart LR
|
||||
START(["Research complete — Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
|
||||
|
||||
Q --> R1["Firmware or software update available"]
|
||||
R1 --> A1(["No ticket needed<br/>Schedule upgrade · Add note to finding"])
|
||||
|
||||
Q --> R2["Fix is a configuration change only"]
|
||||
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
|
||||
|
||||
Q --> R3["Not applicable to this platform / version"]
|
||||
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
|
||||
|
||||
Q --> R4["Patch not yet available from vendor"]
|
||||
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
|
||||
|
||||
Q --> R5["Device is EOL / EOS or business constraint"]
|
||||
R5 --> A5(["Archer ticket with mitigation steps<br/>and remediation plan"])
|
||||
|
||||
Q --> R6["Asset not owned by our BU"]
|
||||
R6 --> A6(["CARD queue — CARD disposition process"])
|
||||
|
||||
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
|
||||
|
||||
class START,Q q
|
||||
class R1,A1 green
|
||||
class R2,A2,R4,A4,R5,A5 amber
|
||||
class R3,A3 red
|
||||
class R6,A6 teal
|
||||
```
|
||||
402
docs/security-posture-workflow.md
Normal file
402
docs/security-posture-workflow.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Security Posture Workflow — Host Finding Review & Remediation
|
||||
|
||||
**Document Type:** Process Guide
|
||||
**Applies To:** STEAM Security Dashboard — All Pages
|
||||
**Audience:** NTS-AEO-STEAM / NTS-AEO-ACCESS-ENG team members
|
||||
**Last Updated:** 2026-03-27
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Dashboard Orientation](#2-dashboard-orientation)
|
||||
3. [Vulnerability Designations](#3-vulnerability-designations)
|
||||
4. [The Host Finding Review Workflow](#4-the-host-finding-review-workflow)
|
||||
- [Step 1 — Sync and Sort by Due Date](#step-1--sync-and-sort-by-due-date)
|
||||
- [Step 2 — Identify the Host](#step-2--identify-the-host)
|
||||
- [Step 3 — Identify Asset Ownership](#step-3--identify-asset-ownership)
|
||||
- [Step 4 — Review the CVEs in the Finding](#step-4--review-the-cves-in-the-finding)
|
||||
- [Step 5 — Determine and Execute the Required Action](#step-5--determine-and-execute-the-required-action)
|
||||
5. [Using the Ivanti Queue](#5-using-the-ivanti-queue)
|
||||
6. [Workflow Status Reference](#6-workflow-status-reference)
|
||||
7. [Quick Reference Card](#7-quick-reference-card)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The STEAM Security Dashboard centralises vulnerability management for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. It pulls host findings directly from Ivanti/RiskSense and gives the team a single place to triage, track, and action every open vulnerability.
|
||||
|
||||
**Scope:** This document covers severity findings in the **8.5 – 9.9 VRR range**. All findings in this range require some form of documented action. A finding that is not actioned before its Due Date results in the device being recorded as non-compliant.
|
||||
|
||||
> **SLA Rule:** By default, all vulnerabilities must have an action taken or in-flight within **60 days of detection**. The Due Date column on the Reporting page shows the exact deadline. Metrics and compliance reporting are based on vulnerabilities aged under 60 days.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard Orientation
|
||||
|
||||
### Pages
|
||||
|
||||
| Page | Purpose |
|
||||
|------|---------|
|
||||
| **Home (CVE Management)** | Track and research individual CVEs across vendors. Store supporting documentation. Log Archer EXC ticket numbers against CVE/vendor pairs. |
|
||||
| **Reporting (Host Findings)** | The primary operational page. Live view of all open Ivanti findings with filtering, sorting, inline editing, the Ivanti Queue, and export. |
|
||||
| **Knowledge Base** | Internal document library — policies, runbooks, vendor advisories. |
|
||||
| **Exports** | Bulk export tools for reports and data extracts. |
|
||||
|
||||
### Reporting Page — At a Glance
|
||||
|
||||
When you open the Reporting page for the first time in a session, click **Sync** (top right) to pull the latest findings from Ivanti. The page shows:
|
||||
|
||||
- **Four metric charts** at the top — Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status
|
||||
- **Findings table** below — every open finding for the configured BUs, one row per host finding
|
||||
- **Ivanti Queue panel** (click the Queue button, top right) — your personal staging list for batch-processing FP and Archer workflows
|
||||
|
||||
The charts and table update together. Clicking a chart segment filters the table to that subset.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vulnerability Designations
|
||||
|
||||
Every finding in the 8.5–9.9 range requires one of three documented actions. Understanding these upfront makes triage faster.
|
||||
|
||||
### 3.1 Remediation
|
||||
|
||||
The vulnerability is addressed by fixing the root cause.
|
||||
|
||||
| Remediation Method | Archer Ticket Required? | Notes |
|
||||
|---|---|---|
|
||||
| Firmware or software update | **No** | Upgrading removes the vulnerability entirely. The finding will fall off the report on the next scan. |
|
||||
| Configuration change | **Yes** | A config change does not remove the vulnerability — if the config is ever rolled back, the vulnerability returns. An Archer Risk Acceptance ticket is required to document this. |
|
||||
|
||||
### 3.2 False Positive (FP)
|
||||
|
||||
A false positive occurs when the scanner detects a vulnerability that is **not actually present** or **does not apply** to the platform or software version in use.
|
||||
|
||||
**An FP workflow must be opened in Ivanti.** The workflow requires:
|
||||
|
||||
1. A **screenshot** taken directly from the device showing:
|
||||
- Hostname
|
||||
- IP address
|
||||
- Software / firmware version
|
||||
> **Important:** This must be a screenshot. CLI text output or copy-pasted command output is not accepted.
|
||||
|
||||
2. **Vendor documentation** confirming the vulnerability does not affect the platform — one of:
|
||||
- Direct vendor communication (email, support ticket)
|
||||
- Published security advisory stating the version or platform is not affected
|
||||
- Proof that the vulnerability does not apply to the currently installed version
|
||||
|
||||
Supporting files (screenshots, emails, advisories) should be saved into the CVE Database (Home page → upload documents against the relevant CVE/vendor pair) for future reference and re-use if the FP expires and needs to be renewed.
|
||||
|
||||
### 3.3 Risk Acceptance / Archer Request
|
||||
|
||||
An Archer Risk Acceptance ticket (EXC-XXXXX) is required when a vulnerability **cannot be patched** for a documented business or technical reason. Common scenarios:
|
||||
|
||||
| Scenario | Required Action |
|
||||
|---|---|
|
||||
| Patch not yet available (waiting on vendor) | Open Archer ticket; close it when patch is deployed |
|
||||
| Device is End-of-Sale (EOS) or End-of-Life (EOL) | Archer ticket required with mitigation steps and a remediation plan |
|
||||
| Business constraint prevents patching | Archer ticket with justification and compensating controls |
|
||||
| Configuration-change-only remediation | Archer ticket required (see Remediation above) |
|
||||
|
||||
For EOL/EOS devices the ticket must include:
|
||||
- Current mitigation steps (network segmentation, compensating controls)
|
||||
- A remediation plan — what will replace or retire the device and when
|
||||
|
||||
If vendor communication is needed (patch timeline, configuration guidance), open a vendor support ticket and use the vendor's response to fill out the Archer remediation plan field.
|
||||
|
||||
> Archer EXC numbers are tracked in the dashboard. Once entered on the Home page against the relevant CVE/vendor pair, the EXC badge appears on that CVE row. Clicking the badge navigates to the Reporting page pre-filtered to findings with that EXC number in their notes.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Host Finding Review Workflow
|
||||
|
||||
Work through the Reporting page top-to-bottom by Due Date. The goal of each session is to ensure every finding either has an action in-flight or gets one started.
|
||||
|
||||
---
|
||||
|
||||
### Step 1 — Sync and Sort by Due Date
|
||||
|
||||
1. Navigate to the **Reporting** page.
|
||||
2. Click **Sync** (top right). Wait for the sync to complete — the timestamp updates when done.
|
||||
3. Click the **Due Date** column header to sort ascending (soonest due date first).
|
||||
- Red due dates = overdue
|
||||
- Amber due dates = due within 30 days
|
||||
- Start with red, then amber
|
||||
|
||||
> If you want to focus on findings with no action yet, click the **Pending** segment on the Action Coverage donut chart. The table will filter to only findings with no FP ticket and no EXC number in notes.
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Identify the Host
|
||||
|
||||
Each finding row includes a **Host** (hostname), **IP Address**, and **DNS** column.
|
||||
|
||||
1. Use the reported **IP address** to verify the hostname in:
|
||||
- **IPControl** (read-only, historical IPAM data)
|
||||
- **Infoblox** (current IPAM — preferred for current state)
|
||||
|
||||
2. If the hostname shown in the dashboard is incorrect (Ivanti sometimes reports stale data):
|
||||
- Click the **Host** cell in the finding row — it is inline editable.
|
||||
- Type the correct hostname and press **Enter** or click away to save.
|
||||
- An amber dot (●) will appear on the cell to indicate an override is in place. The original Ivanti value is preserved and can be restored using the revert button (↻).
|
||||
- The same applies to the **DNS** column.
|
||||
|
||||
> Overrides survive Ivanti re-syncs — your corrections are not overwritten when new data is pulled.
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Identify Asset Ownership
|
||||
|
||||
Check the **BU** column to determine ownership.
|
||||
|
||||
| BU Value | Ownership | Action |
|
||||
|---|---|---|
|
||||
| `NTS-AEO-STEAM` | Our team | Continue to Step 4 |
|
||||
| `NTS-AEO-ACCESS-ENG` | Our team | Continue to Step 4 |
|
||||
| Any other value, or blank | Not our asset | Add to CARD queue (see below) |
|
||||
|
||||
**If the asset is not owned by our BU:**
|
||||
|
||||
1. Check the checkbox at the left of the finding row.
|
||||
2. A popover will appear. The **CARD** workflow type should already be selected.
|
||||
- No vendor entry is required for CARD — the IP address is captured automatically for use when searching in CARD.
|
||||
3. Click **Add to Queue**.
|
||||
4. The finding is now staged in your Ivanti Queue under the **CARD** section.
|
||||
|
||||
CARD queue items are processed in a separate session — see the [Ivanti Queue](#5-using-the-ivanti-queue) section and the dedicated CARD process documentation.
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Review the CVEs in the Finding
|
||||
|
||||
Each finding has one or more CVEs listed in the **CVEs** column (up to 2 shown; hover the "+N" badge to see the rest).
|
||||
|
||||
For each CVE in the finding:
|
||||
|
||||
1. **Check if the CVE already exists in the database.**
|
||||
- Navigate to the **Home** page.
|
||||
- Search for the CVE ID in the search bar.
|
||||
- If an entry exists for this CVE and vendor, review what's already documented — there may be existing notes, documents, or an Archer ticket already linked.
|
||||
|
||||
2. **If no entry exists, create one:**
|
||||
- Click **Add CVE** on the Home page.
|
||||
- Enter the CVE ID — the NVD auto-fill will populate the description, CVSS severity, and published date automatically.
|
||||
- Select the correct vendor/platform.
|
||||
- Save the entry.
|
||||
|
||||
3. **Research the CVE** to determine the required action:
|
||||
- Check the vendor's security advisory portal (e.g., Juniper Security Advisories, Cisco Security Advisories / Bug Search Tool)
|
||||
- Determine whether the CVE: (a) is a False Positive for this platform/version, (b) can be Remediated, or (c) requires a Risk Acceptance
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — Determine and Execute the Required Action
|
||||
|
||||
Based on your research in Step 4, choose the path below.
|
||||
|
||||
---
|
||||
|
||||
#### Path A — Remediation (Firmware or Software Update)
|
||||
|
||||
> No Archer ticket required if the fix is a firmware or software upgrade.
|
||||
|
||||
1. Plan and schedule the upgrade with the relevant team.
|
||||
2. No dashboard action is required beyond ensuring a note is added to the finding (click the **Notes** cell) confirming the upgrade is planned or complete.
|
||||
3. After the device is upgraded, the finding will fall off the Reporting page on the next Ivanti scan if the vulnerability is no longer detected.
|
||||
|
||||
---
|
||||
|
||||
#### Path B — Remediation (Configuration Change)
|
||||
|
||||
> An Archer Risk Acceptance ticket **is required** when the fix is a configuration change.
|
||||
|
||||
1. Check the checkbox at the left of the finding row.
|
||||
2. In the popover, enter the **Vendor / Platform** (e.g., Juniper, Cisco, ADTRAN).
|
||||
3. Select **Archer** as the workflow type.
|
||||
4. Click **Add to Queue**.
|
||||
5. Process the Archer ticket in a dedicated session — see [Ivanti Queue](#5-using-the-ivanti-queue) and the Archer process documentation.
|
||||
|
||||
---
|
||||
|
||||
#### Path C — False Positive
|
||||
|
||||
1. **Collect the required evidence:**
|
||||
- Log into the device and **take a screenshot** showing the hostname, IP address, and software/firmware version.
|
||||
- Obtain vendor documentation confirming the CVE does not affect this platform or version (security advisory, vendor email, etc.).
|
||||
|
||||
2. **Save supporting files to the database:**
|
||||
- Go to the Home page and find (or create) the CVE entry for this vendor.
|
||||
- Upload the screenshot as type `screenshot` and the vendor communication as type `advisory` or `email`.
|
||||
- This ensures the evidence is accessible when the FP expires and needs to be renewed.
|
||||
|
||||
3. **Stage the finding in the queue:**
|
||||
- Check the checkbox at the left of the finding row on the Reporting page.
|
||||
- Enter the **Vendor / Platform**.
|
||||
- Select **FP** as the workflow type.
|
||||
- Click **Add to Queue**.
|
||||
|
||||
4. **Open the False Positive workflow in Ivanti:**
|
||||
- Process queued FP items in a dedicated session.
|
||||
- See the dedicated FP workflow documentation for the full Ivanti submission steps.
|
||||
|
||||
---
|
||||
|
||||
#### Path D — Risk Acceptance (Archer Ticket)
|
||||
|
||||
1. **Collect information** as you would for a False Positive (device screenshot, version info).
|
||||
2. If vendor communication is required (patch timeline, EOL statement, recommended mitigations):
|
||||
- Open a vendor support ticket requesting remediation steps, configuration guidance, or a patch commitment date.
|
||||
- Use the vendor's response to fill out the Archer remediation plan.
|
||||
3. **Stage the finding in the queue:**
|
||||
- Check the checkbox on the finding row.
|
||||
- Enter the **Vendor / Platform**.
|
||||
- Select **Archer** as the workflow type.
|
||||
- Click **Add to Queue**.
|
||||
4. **Open the Archer Risk Acceptance ticket:**
|
||||
- Process queued Archer items in a dedicated session.
|
||||
- See the dedicated Archer process documentation for the full submission steps.
|
||||
5. Once the EXC number is assigned, enter it in the finding's **Notes** cell on the Reporting page (format: `EXC-XXXXX`). The dashboard will recognise the pattern and include it in the Action Coverage chart under "Archer Exception".
|
||||
|
||||
---
|
||||
|
||||
## 5. Using the Ivanti Queue
|
||||
|
||||
The Ivanti Queue is a personal staging list built into the Reporting page. Rather than interrupting your review to context-switch into Ivanti, you tag findings as you go and then batch-process all the Ivanti work in one focused session.
|
||||
|
||||
### Adding Items to the Queue
|
||||
|
||||
1. On the Reporting page, check the **checkbox at the far left** of any finding row.
|
||||
2. A popover appears anchored to the row.
|
||||
3. For **FP** and **Archer** items: enter the **Vendor / Platform** (free text — e.g., "Juniper MX", "Cisco IOS-XE").
|
||||
4. Select the **workflow type**:
|
||||
- **FP** — False Positive request to be submitted in Ivanti
|
||||
- **Archer** — Archer Risk Acceptance ticket to be opened
|
||||
- **CARD** — Asset not owned by our BU; IP address is captured automatically
|
||||
5. Click **Add to Queue**. The row checkbox turns solid blue to indicate it is queued.
|
||||
|
||||
### Opening the Queue Panel
|
||||
|
||||
Click the **Queue** button in the top-right of the Reporting page. A slide-out panel opens from the right showing all your queued items.
|
||||
|
||||
- **CARD** items appear at the top of the panel in their own green section, with the IP address displayed for easy CARD search.
|
||||
- **FP and Archer** items are grouped alphabetically by vendor/platform below.
|
||||
- Each item shows: Finding ID, CVEs (or IP for CARD), and the workflow type badge (amber = FP, sky = Archer, green = CARD).
|
||||
|
||||
### Working the Queue
|
||||
|
||||
**Marking items complete:**
|
||||
Once you have submitted the FP or Archer ticket in Ivanti (or actioned the CARD item), check the item's green checkbox to mark it complete. Completed items are shown with a strikethrough at reduced opacity.
|
||||
|
||||
**Deleting items:**
|
||||
- Click the trash icon on an individual item to remove it.
|
||||
- To remove multiple items at once: check the small red selection checkbox on the left of each item you want to remove, then click **Delete (N)** in the footer.
|
||||
|
||||
**Clearing completed items:**
|
||||
Click **Clear Completed** in the footer to remove all marked-complete items at once.
|
||||
|
||||
> Queue items are stored in the database and are **personal to your login** — they persist across sessions and page refreshes. Other team members see only their own queue.
|
||||
|
||||
---
|
||||
|
||||
## 6. Workflow Status Reference
|
||||
|
||||
The **Workflow** column on the Reporting page tracks FP# tickets — False Positive requests submitted in Ivanti. The badge shows the ticket ID and its current state, colour-coded by urgency.
|
||||
|
||||
> SYS# workflows are auto-generated system tracking records. They are not displayed and do not require team action.
|
||||
|
||||
### Status Colour Codes
|
||||
|
||||
#### 🔴 Red — Act Immediately
|
||||
|
||||
| State | Meaning | Required Action |
|
||||
|---|---|---|
|
||||
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding has re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||
| **Rejected** | The security team reviewed the FP and denied it. The finding is a confirmed, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||
|
||||
#### 🟡 Amber — Action Required Soon
|
||||
|
||||
| State | Meaning | Required Action |
|
||||
|---|---|---|
|
||||
| **Reworked** | The FP request was challenged by the reviewer and returned for revision. | Open the ticket in Ivanti, review the feedback, update the justification, and **resubmit**. |
|
||||
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti and respond to what is required. |
|
||||
|
||||
#### 🔵 Blue — In Flight, Monitor
|
||||
|
||||
| State | Meaning | Required Action |
|
||||
|---|---|---|
|
||||
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If the SLA window is approaching with no response, follow up with the approver. |
|
||||
|
||||
#### — (No Badge) — Untriaged
|
||||
|
||||
| State | Meaning | Required Action |
|
||||
|---|---|---|
|
||||
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding using the workflow in Section 4. Determine whether to remediate, submit an FP, or open an Archer ticket. |
|
||||
|
||||
### Decision Flowchart
|
||||
|
||||
```
|
||||
Finding appears in Reporting page
|
||||
│
|
||||
├── Check the Workflow column
|
||||
│
|
||||
├── No badge (—)
|
||||
│ └── Triage → follow Section 4 workflow
|
||||
│
|
||||
└── Has a badge → check the colour:
|
||||
│
|
||||
├── 🔵 BLUE (Requested)
|
||||
│ └── Monitor. Follow up if SLA window is approaching.
|
||||
│
|
||||
├── 🟡 AMBER (Reworked / Actionable)
|
||||
│ └── Open Ivanti ticket → review feedback → update → resubmit
|
||||
│
|
||||
└── 🔴 RED
|
||||
│
|
||||
├── Expired → Submit a new FP request in Ivanti
|
||||
│
|
||||
└── Rejected → Remediate the vulnerability
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick Reference Card
|
||||
|
||||
### Action Decision Matrix
|
||||
|
||||
| Research Outcome | Config Change? | Action Required |
|
||||
|---|---|---|
|
||||
| Can be patched (firmware/software) | N/A | Upgrade device — no ticket needed |
|
||||
| Can be patched (configuration change only) | Yes | Archer Risk Acceptance ticket (EXC-XXXXX) |
|
||||
| False Positive — not applicable to platform/version | N/A | FP workflow in Ivanti + evidence in CVE database |
|
||||
| Cannot be patched — patch pending from vendor | N/A | Archer Risk Acceptance ticket (renew when patched) |
|
||||
| Cannot be patched — EOL/EOS device | N/A | Archer ticket with mitigation steps + remediation plan |
|
||||
| Asset not owned by our BU | N/A | CARD queue → CARD asset disposition process |
|
||||
|
||||
### Workflow Badge Quick Reference
|
||||
|
||||
| Badge | State | One-Line Action |
|
||||
|---|---|---|
|
||||
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||
| 🟡 Amber | Actionable | Review ticket in Ivanti and respond |
|
||||
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||
| — | No badge | Triage: follow Section 4 |
|
||||
|
||||
### Dashboard Shortcut Reference
|
||||
|
||||
| Task | How |
|
||||
|---|---|
|
||||
| See only untriaged findings | Click **Pending** segment on Action Coverage chart |
|
||||
| See findings due this week | Click a date on the Home page calendar widget |
|
||||
| See all findings for a specific Archer ticket | Click the EXC badge on the Home page CVE row |
|
||||
| Correct a wrong hostname | Click the Host cell inline on the Reporting page |
|
||||
| Save a screenshot or advisory to a CVE | Home page → CVE row → Upload document |
|
||||
| Stage findings for a batch FP/Archer session | Use the Ivanti Queue (checkbox column on Reporting page) |
|
||||
| Filter to a specific vendor or SLA status | Click the filter icon (⊙) on the relevant column header |
|
||||
|
||||
---
|
||||
|
||||
*Related documentation: FP Workflow Submission (Ivanti) · Archer Risk Acceptance Process · CARD Asset Disposition Process · MOP: Workflow Status Colour Codes*
|
||||
158
docs/team-training-agenda.md
Normal file
158
docs/team-training-agenda.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# STEAM Security Dashboard — Team Training Agenda
|
||||
|
||||
**Session length:** 30–40 minutes
|
||||
**Format:** Live walkthrough (share your screen on the dashboard)
|
||||
**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here
|
||||
|
||||
---
|
||||
|
||||
## Pre-meeting prep
|
||||
|
||||
- Have the dashboard open and logged in before the meeting starts
|
||||
- Sync Vulnerability Triage page so data is fresh when you get there
|
||||
- Print or share `security-posture-workflow.md` as a take-home reference
|
||||
|
||||
---
|
||||
|
||||
## Segment 1 — Why this tool exists (3 min)
|
||||
|
||||
**Talking points:**
|
||||
- We have open Ivanti findings in the 8.5–9.9 VRR range — these are the ones we own and are accountable for
|
||||
- Every finding needs a documented action within **60 days of detection** (the SLA rule)
|
||||
- Findings that age past their Due Date make a device non-compliant in AEO posture reporting
|
||||
- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking
|
||||
|
||||
---
|
||||
|
||||
## Segment 2 — Dashboard orientation (4 min)
|
||||
|
||||
**Show on screen:** Navigate through each page in the nav drawer
|
||||
|
||||
- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair
|
||||
- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time
|
||||
- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team
|
||||
- **Knowledge Base** — internal docs, runbooks, advisories
|
||||
- **Exports** — bulk data extracts when needed
|
||||
|
||||
> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."*
|
||||
|
||||
---
|
||||
|
||||
## Segment 3 — The three things you can do with a finding (5 min)
|
||||
|
||||
**Talking points — before showing the table, set context:**
|
||||
|
||||
Every finding in our range gets one of three designations:
|
||||
|
||||
1. **Remediation** — you fix the root cause
|
||||
- Firmware/software upgrade → no ticket needed, finding drops off on next scan
|
||||
- Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know)
|
||||
|
||||
2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version
|
||||
- Requires an FP workflow opened in Ivanti
|
||||
- Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us
|
||||
- Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires
|
||||
|
||||
3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason
|
||||
- Vendor hasn't released a patch yet
|
||||
- Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket
|
||||
- Business constraint — needs justification and compensating controls
|
||||
- Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created
|
||||
|
||||
> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."*
|
||||
|
||||
---
|
||||
|
||||
## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min)
|
||||
|
||||
**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding
|
||||
|
||||
### Step 1 — Sync and sort (1 min)
|
||||
- Click **Sync** top-right, wait for timestamp to update
|
||||
- Click **Due Date** column to sort ascending — reds first, then ambers
|
||||
- Red = overdue, Amber = due within 30 days — work these first
|
||||
|
||||
### Step 2 — Identify the host (3 min)
|
||||
- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl
|
||||
- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable
|
||||
- An amber dot appears on overridden cells; original value is preserved and can be restored
|
||||
- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be
|
||||
|
||||
### Step 3 — Check who owns the asset (2 min)
|
||||
- Look at the **BU column**
|
||||
- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue
|
||||
- Anything else (or blank) → not ours → **CARD queue**
|
||||
- Check the row checkbox, select CARD, click Add to Queue
|
||||
- IP address is captured automatically for the CARD search
|
||||
- Process CARD items in a separate session
|
||||
|
||||
### Step 4 — Look up the CVEs (4 min)
|
||||
- Each row shows up to 2 CVEs; hover the **+N badge** to see more
|
||||
- Go to Home page, search for the CVE ID
|
||||
- If it exists → review existing notes, docs, and any EXC numbers already linked
|
||||
- If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest
|
||||
- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket
|
||||
|
||||
### Step 5 — Take action (5 min)
|
||||
- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done
|
||||
- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later
|
||||
- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session
|
||||
- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format)
|
||||
|
||||
---
|
||||
|
||||
## Segment 5 — The Ivanti Queue (5 min)
|
||||
|
||||
**Show on screen:** Click the Queue button, show the panel
|
||||
|
||||
- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly
|
||||
- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green)
|
||||
- CARD items show the IP address so you can search directly in CARD
|
||||
- Check the green checkbox on an item when the Ivanti/Archer action is done
|
||||
- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer
|
||||
- Queue is **personal to your login** — each person has their own; it persists across sessions
|
||||
|
||||
---
|
||||
|
||||
## Segment 6 — Workflow badge colours (3 min)
|
||||
|
||||
**Show on screen:** Workflow column on the Vulnerability Triage table
|
||||
|
||||
Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage**
|
||||
|
||||
| Badge | What it means | What to do |
|
||||
|---|---|---|
|
||||
| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti |
|
||||
| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence |
|
||||
| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit |
|
||||
| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond |
|
||||
| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching |
|
||||
| No badge | Never been triaged | Run it through the 5-step workflow |
|
||||
|
||||
---
|
||||
|
||||
## Segment 7 — Quick tips (2 min)
|
||||
|
||||
Quick features worth pointing out before Q&A:
|
||||
|
||||
- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart
|
||||
- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row
|
||||
- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header
|
||||
- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files
|
||||
|
||||
---
|
||||
|
||||
## Segment 8 — Q&A (remaining time)
|
||||
|
||||
Suggested prompts to open discussion if no questions come up:
|
||||
- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."*
|
||||
- *"When would you use the Ivanti Queue versus just actioning something immediately?"*
|
||||
- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"*
|
||||
|
||||
---
|
||||
|
||||
## Takeaway for the team
|
||||
|
||||
Point them to:
|
||||
- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix
|
||||
- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners
|
||||
333
docs/time-based-reporting-recommendations.md
Normal file
333
docs/time-based-reporting-recommendations.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Time-Based Reporting Recommendations
|
||||
**Date:** 2026-04-02
|
||||
**Author:** Engineering (Claude Code)
|
||||
**Status:** Draft — for director review
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document analyzes the current CVE Dashboard data model and recommends a set of time-based visualizations that can be added to the Reporting page. Recommendations are grouped by feasibility: **Tier 1** can be built with data already in the database, **Tier 2** requires a lightweight new tracking table, and **Tier 3** requires structural additions.
|
||||
|
||||
---
|
||||
|
||||
## Current Data Inventory
|
||||
|
||||
### What Already Has Time-Series History
|
||||
|
||||
| Source | Table | Date Fields | History? |
|
||||
|--------|-------|-------------|----------|
|
||||
| Compliance uploads | `compliance_uploads` | `report_date`, `uploaded_at` | **Yes** — one row per report cycle |
|
||||
| Compliance items | `compliance_items` | `created_at`, `first_seen_upload_id`, `resolved_upload_id` | **Yes** — tracks lifecycle |
|
||||
| Archer tickets | `archer_tickets` | `created_at`, `updated_at` | **Yes** — full history |
|
||||
| Todo queue | `ivanti_todo_queue` | `created_at`, `updated_at` | **Yes** — by action |
|
||||
| Finding notes | `ivanti_finding_notes` | `updated_at` | **Yes** — note activity |
|
||||
|
||||
### What Is Point-in-Time Only (no history yet)
|
||||
|
||||
| Source | Table | Problem |
|
||||
|--------|-------|---------|
|
||||
| Ivanti findings | `ivanti_findings_cache` | Single-row cache — overwritten on every sync |
|
||||
| Ivanti counts | `ivanti_counts_cache` | Single-row cache — no snapshots stored |
|
||||
| FP workflow states | Computed from `findings_json` | Ephemeral — not persisted historically |
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 Recommendations — Build Now (No Schema Changes)
|
||||
|
||||
All of these use data that is already in the database.
|
||||
|
||||
---
|
||||
|
||||
### 1.1 Compliance Trend Line — Total Active Findings Over Time
|
||||
|
||||
**Description:** A line chart showing the total number of active (non-compliant) items per compliance upload date. This directly answers "are we improving over time?"
|
||||
|
||||
**Data Source:**
|
||||
```sql
|
||||
SELECT
|
||||
cu.report_date,
|
||||
COUNT(ci.id) AS active_count
|
||||
FROM compliance_uploads cu
|
||||
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||
GROUP BY cu.id
|
||||
ORDER BY cu.report_date ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Line chart with data points per upload
|
||||
**Axes:** X = Report Date, Y = Number of Active Findings
|
||||
**Value-add:** Overlay a trend line (linear regression) to show trajectory
|
||||
|
||||
---
|
||||
|
||||
### 1.2 New / Recurring / Resolved Bar Chart — Per Report Cycle
|
||||
|
||||
**Description:** A grouped or stacked bar chart showing the delta breakdown for each compliance upload: how many findings were newly introduced, how many recurred from a prior cycle, and how many were resolved.
|
||||
|
||||
**Data Source:** Already computed and stored in `compliance_uploads`:
|
||||
```sql
|
||||
SELECT report_date, new_count, recurring_count, resolved_count
|
||||
FROM compliance_uploads
|
||||
ORDER BY report_date ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Stacked bar chart (one bar per upload date)
|
||||
**Legend:** New (red/amber), Recurring (yellow), Resolved (green)
|
||||
**Value-add:** Shows whether each reporting cycle is improving (more resolved than new) or degrading
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Team Compliance Health Over Time — Multi-Line Chart
|
||||
|
||||
**Description:** A multi-line chart showing the active finding count per team per upload date. Answers "which team is trending better or worse?"
|
||||
|
||||
**Data Source:**
|
||||
```sql
|
||||
SELECT
|
||||
cu.report_date,
|
||||
ci.team,
|
||||
COUNT(ci.id) AS active_count
|
||||
FROM compliance_uploads cu
|
||||
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||
GROUP BY cu.id, ci.team
|
||||
ORDER BY cu.report_date ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Multi-line chart (one line per team)
|
||||
**Teams:** STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV
|
||||
**Value-add:** Immediately visible which team is outlier or improving fastest
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Mean Time to Resolution (MTTR) — Per Team
|
||||
|
||||
**Description:** A bar chart showing average number of upload cycles between when a finding first appeared and when it was resolved, broken out by team.
|
||||
|
||||
**Data Source:**
|
||||
```sql
|
||||
SELECT
|
||||
ci.team,
|
||||
AVG(ci.resolved_upload_id - ci.first_seen_upload_id) AS avg_cycles_to_resolve,
|
||||
COUNT(*) AS resolved_count
|
||||
FROM compliance_items ci
|
||||
WHERE ci.resolved_upload_id IS NOT NULL
|
||||
GROUP BY ci.team;
|
||||
```
|
||||
|
||||
**Chart Type:** Horizontal bar chart
|
||||
**Axes:** Y = Team, X = Average Cycles to Resolution
|
||||
**Value-add:** Normalize to calendar days by joining with upload dates for true MTTR in days
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Recurring Findings Heatmap — Seen Count Distribution
|
||||
|
||||
**Description:** A heatmap or bubble chart showing findings grouped by how many times they have recurred (`seen_count`). Identifies chronic, long-standing compliance gaps.
|
||||
|
||||
**Data Source:**
|
||||
```sql
|
||||
SELECT
|
||||
team,
|
||||
metric_id,
|
||||
metric_desc,
|
||||
seen_count,
|
||||
COUNT(*) AS host_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY team, metric_id
|
||||
ORDER BY seen_count DESC;
|
||||
```
|
||||
|
||||
**Chart Type:** Horizontal bar chart sorted by `seen_count`, grouped by team
|
||||
**Value-add:** Highlights the "chronic" findings that repeatedly appear — high priority for remediation
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Archer Exception Ticket Status Over Time
|
||||
|
||||
**Description:** A line chart or cumulative area chart showing Archer ticket status transitions over time using `created_at` and `updated_at`.
|
||||
|
||||
**Data Source:**
|
||||
```sql
|
||||
SELECT
|
||||
DATE(created_at) AS date,
|
||||
status,
|
||||
COUNT(*) AS count
|
||||
FROM archer_tickets
|
||||
GROUP BY DATE(created_at), status
|
||||
ORDER BY date ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Stacked area chart
|
||||
**Statuses:** Draft, Open, Under Review, Accepted
|
||||
**Value-add:** Tracks exception request pipeline velocity — are exceptions getting processed or stacking up?
|
||||
|
||||
---
|
||||
|
||||
### 1.7 Compliance Category Breakdown Over Time
|
||||
|
||||
**Description:** A stacked area chart showing what categories of compliance failures are driving the total over time (if the `category` field in `compliance_items` is populated).
|
||||
|
||||
**Data Source:**
|
||||
```sql
|
||||
SELECT
|
||||
cu.report_date,
|
||||
ci.category,
|
||||
COUNT(ci.id) AS count
|
||||
FROM compliance_uploads cu
|
||||
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||
WHERE ci.category IS NOT NULL
|
||||
GROUP BY cu.id, ci.category
|
||||
ORDER BY cu.report_date ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Stacked area chart
|
||||
**Value-add:** Shows whether one category dominates or if failures are spread across areas
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 Recommendations — Lightweight Schema Addition Required
|
||||
|
||||
These require adding one new table to persist snapshots of data that is currently overwritten on each sync.
|
||||
|
||||
---
|
||||
|
||||
### 2.1 Ivanti Findings Count Over Time — Open vs Closed Trend
|
||||
|
||||
**Description:** The single most-requested metric: "are we making progress on vulnerabilities?" A line chart showing open and closed Ivanti finding counts over time.
|
||||
|
||||
**Problem:** The current `ivanti_counts_cache` is a single-row table overwritten on each sync. No history is kept.
|
||||
|
||||
**Solution:** Add a `ivanti_counts_history` table and append a row on every successful sync:
|
||||
```sql
|
||||
CREATE TABLE ivanti_counts_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_count INTEGER NOT NULL,
|
||||
closed_count INTEGER NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**Backend change:** In the sync route (`POST /api/ivanti/findings/sync`), after updating the cache, also `INSERT INTO ivanti_counts_history`.
|
||||
|
||||
**New API endpoint:** `GET /api/ivanti/findings/counts/history`
|
||||
```sql
|
||||
SELECT open_count, closed_count, recorded_at
|
||||
FROM ivanti_counts_history
|
||||
ORDER BY recorded_at ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Dual-line chart
|
||||
**Lines:** Open findings (red), Closed findings (green)
|
||||
**Value-add:** Most direct measure of vulnerability remediation velocity
|
||||
|
||||
---
|
||||
|
||||
### 2.2 FP Workflow State Snapshots Over Time
|
||||
|
||||
**Description:** A stacked area or line chart showing how FP workflow states (Actionable, Requested, Approved, Rejected, Expired) trend over sync cycles.
|
||||
|
||||
**Solution:** Add a `ivanti_fp_workflow_history` table:
|
||||
```sql
|
||||
CREATE TABLE ivanti_fp_workflow_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
state TEXT NOT NULL,
|
||||
finding_count INTEGER NOT NULL DEFAULT 0,
|
||||
id_count INTEGER NOT NULL DEFAULT 0,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**Chart Type:** Stacked area chart
|
||||
**Value-add:** Shows whether FP requests are being worked through or stacking up in "Requested" state
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Todo Queue Velocity — Items Added vs Completed Per Week
|
||||
|
||||
**Description:** A bar chart showing weekly queue throughput (items added vs items marked complete).
|
||||
|
||||
**Data Source:** Already available in `ivanti_todo_queue.created_at` and `updated_at` + `status = 'complete'`:
|
||||
```sql
|
||||
SELECT
|
||||
STRFTIME('%Y-W%W', created_at) AS week,
|
||||
COUNT(*) AS items_added,
|
||||
SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) AS items_completed
|
||||
FROM ivanti_todo_queue
|
||||
GROUP BY week
|
||||
ORDER BY week ASC;
|
||||
```
|
||||
|
||||
**Chart Type:** Grouped bar chart (weekly)
|
||||
**Value-add:** Measures operational pace of the team's workflow action throughput
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 Recommendations — Structural Additions (Future Consideration)
|
||||
|
||||
These require more significant changes but would provide powerful long-term reporting.
|
||||
|
||||
---
|
||||
|
||||
### 3.1 Finding Age / Dwell Time Distribution
|
||||
|
||||
**Description:** A histogram showing how long open findings have been open (age in days). The `lastFoundOn` field exists in the Ivanti findings JSON but is not persisted to a structured table.
|
||||
|
||||
**Requirement:** Parse and store `lastFoundOn` from findings JSON into a structured column during sync.
|
||||
|
||||
**Value-add:** Highlights findings that have been open for 90+ days — high-priority remediation targets.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 SLA Breach Trends
|
||||
|
||||
**Description:** Track how many findings breach SLA (Due Date exceeded) over time. Currently SLA status is computed in the frontend on-the-fly.
|
||||
|
||||
**Requirement:** Add SLA breach tracking during sync — stamp findings that cross SLA date.
|
||||
|
||||
**Value-add:** Compliance and audit reporting for SLA adherence metrics.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
| Priority | Chart | Effort | Impact |
|
||||
|----------|-------|--------|--------|
|
||||
| 1 | 1.2 — New/Recurring/Resolved bar chart | Low (data ready) | High |
|
||||
| 2 | 1.1 — Compliance trend line | Low (data ready) | High |
|
||||
| 3 | 1.3 — Team health multi-line | Low (data ready) | High |
|
||||
| 4 | 2.1 — Ivanti open/closed history | Medium (new table) | Very High |
|
||||
| 5 | 1.4 — MTTR per team | Low (data ready) | Medium |
|
||||
| 6 | 1.6 — Archer ticket pipeline | Low (data ready) | Medium |
|
||||
| 7 | 2.3 — Queue velocity | Low (data ready) | Medium |
|
||||
| 8 | 1.5 — Recurring findings heatmap | Low (data ready) | Medium |
|
||||
| 9 | 2.2 — FP workflow snapshots | Medium (new table) | Medium |
|
||||
| 10 | 1.7 — Category breakdown | Low (data ready) | Low–Medium |
|
||||
|
||||
---
|
||||
|
||||
## Charting Library Consideration
|
||||
|
||||
The current implementation uses **hand-rolled SVG donut charts** (no external library). For time-series line/bar/area charts, the team should decide:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **Continue hand-rolled SVG** | Zero dependencies, full style control | Significant effort for axes, labels, tooltips |
|
||||
| **Recharts** (React-native) | Well-matched to React 19, composable, responsive | ~500KB dependency |
|
||||
| **Chart.js via react-chartjs-2** | Mature, widely documented | Less React-idiomatic |
|
||||
| **Lightweight: uPlot or Chart.xkcd** | Very small bundle | Less community support |
|
||||
|
||||
**Recommendation:** Recharts aligns best with the React 19 stack and allows declaring charts as JSX components consistent with the existing code style. It supports all chart types listed above.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Director Review
|
||||
|
||||
- All **Tier 1** recommendations can be implemented with zero database migrations — the data is already there.
|
||||
- The **single highest-value addition** is `2.1 — Ivanti open/closed count history`, as it captures the most direct remediation progress metric. It only requires one new table and one line added to the sync handler.
|
||||
- **Compliance charts (1.1–1.5)** will only be meaningful once multiple compliance uploads have been committed. If only 1–2 uploads exist currently, the trend will not show much until more data accumulates — but building the charts now means data will automatically populate them.
|
||||
- All queries listed above have been validated against the actual database schema.
|
||||
|
||||
---
|
||||
|
||||
*Next step: Review with director, confirm priority order, then schedule sprint for implementation.*
|
||||
@@ -1,70 +0,0 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
0
frontend/cve_database.db
Normal file
0
frontend/cve_database.db
Normal file
@@ -10,8 +10,11 @@
|
||||
"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"
|
||||
"recharts": "^3.8.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -25,9 +25,67 @@
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>CVE Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'mono': ['JetBrains Mono', 'monospace'],
|
||||
'sans': ['Outfit', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
'intel': {
|
||||
'darkest': '#0A0E27',
|
||||
'dark': '#131937',
|
||||
'medium': '#1E2749',
|
||||
'accent': '#00D9FF',
|
||||
'accent-dim': '#0099BB',
|
||||
'danger': '#FF3366',
|
||||
'warning': '#FFB800',
|
||||
'success': '#00FF88',
|
||||
'grid': '#1E2749',
|
||||
}
|
||||
},
|
||||
backgroundImage: {
|
||||
'grid-pattern': 'linear-gradient(rgba(0, 217, 255, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 217, 255, 0.03) 1px, transparent 1px)',
|
||||
},
|
||||
backgroundSize: {
|
||||
'grid': '20px 20px',
|
||||
},
|
||||
animation: {
|
||||
'scan': 'scan 3s ease-in-out infinite',
|
||||
'pulse-glow': 'pulse-glow 2s ease-in-out infinite',
|
||||
'fade-in': 'fade-in 0.5s ease-out',
|
||||
'slide-up': 'slide-up 0.4s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
'scan': {
|
||||
'0%, 100%': { transform: 'translateY(-100%)', opacity: '0' },
|
||||
'50%': { transform: 'translateY(100%)', opacity: '0.3' },
|
||||
},
|
||||
'pulse-glow': {
|
||||
'0%, 100%': { boxShadow: '0 0 5px rgba(0, 217, 255, 0.3)' },
|
||||
'50%': { boxShadow: '0 0 20px rgba(0, 217, 255, 0.6)' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'slide-up': {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-intel-darkest">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
|
||||
@@ -1,38 +1,825 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
/* Tactical Intelligence Dashboard Styles */
|
||||
/* IMPORTANT: This file MUST be imported in App.js */
|
||||
|
||||
* {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
/* Pulse animation for glowing dots - used by inline styles */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Base Colors - Modern Slate Foundation */
|
||||
--intel-darkest: #0F172A;
|
||||
--intel-dark: #1E293B;
|
||||
--intel-medium: #334155;
|
||||
--intel-accent: #0EA5E9; /* Sky Blue - professional cyan */
|
||||
--intel-warning: #F59E0B; /* Amber - sophisticated warning */
|
||||
--intel-danger: #EF4444; /* Modern Red - urgent but refined */
|
||||
--intel-success: #10B981; /* Emerald - professional green */
|
||||
--intel-grid: rgba(14, 165, 233, 0.08);
|
||||
|
||||
/* Text Colors with proper contrast */
|
||||
--text-primary: #F8FAFC;
|
||||
--text-secondary: #E2E8F0;
|
||||
--text-tertiary: #CBD5E1;
|
||||
--text-muted: #94A3B8;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0F172A;
|
||||
color: #E2E8F0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Utility Classes for Tailwind-style usage */
|
||||
.bg-intel-darkest { background-color: var(--intel-darkest); }
|
||||
.bg-intel-dark { background-color: var(--intel-dark); }
|
||||
.bg-intel-medium { background-color: var(--intel-medium); }
|
||||
.text-intel-accent { color: var(--intel-accent); }
|
||||
.text-intel-warning { color: var(--intel-warning); }
|
||||
.text-intel-danger { color: var(--intel-danger); }
|
||||
.text-intel-success { color: var(--intel-success); }
|
||||
.border-intel-accent { border-color: var(--intel-accent); }
|
||||
.border-intel-warning { border-color: var(--intel-warning); }
|
||||
.border-intel-danger { border-color: var(--intel-danger); }
|
||||
.border-intel-grid { border-color: var(--intel-grid); }
|
||||
|
||||
/* Grid background effect */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(14, 165, 233, 0.025) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* Monospace font for technical data */
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Glowing border effect */
|
||||
.glow-border {
|
||||
position: relative;
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
|
||||
.glow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
background: linear-gradient(45deg, transparent, rgba(14, 165, 233, 0.08), transparent);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.glow-border:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scanning line animation */
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
rgba(14, 165, 233, 0.6),
|
||||
transparent
|
||||
);
|
||||
animation: scan 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
@keyframes scan {
|
||||
0%, 100% {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(2000%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Card hover effects with refined depth */
|
||||
.intel-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.3);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.1),
|
||||
inset 0 -1px 0 rgba(14, 165, 233, 0.05);
|
||||
}
|
||||
|
||||
.intel-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg,
|
||||
transparent,
|
||||
rgba(14, 165, 233, 0.08),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.intel-card:hover {
|
||||
border-color: rgba(14, 165, 233, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||
0 0 30px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.intel-card:hover::after {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Status badges with STRONG glow and contrast */
|
||||
.status-badge {
|
||||
position: relative;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
.status-critical {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.15) 100%);
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
color: #FCA5A5;
|
||||
text-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.status-critical::before {
|
||||
background: #EF4444;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.6), 0 0 6px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.status-high {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(245, 158, 11, 0.15) 100%);
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
color: #FCD34D;
|
||||
text-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.status-high::before {
|
||||
background: #F59E0B;
|
||||
box-shadow: 0 0 12px rgba(245, 158, 11, 0.6), 0 0 6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.status-medium {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2) 0%, rgba(14, 165, 233, 0.15) 100%);
|
||||
border-color: rgba(14, 165, 233, 0.6);
|
||||
color: #7DD3FC;
|
||||
text-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
.status-medium::before {
|
||||
background: #0EA5E9;
|
||||
box-shadow: 0 0 12px rgba(14, 165, 233, 0.6), 0 0 6px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
.status-low {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.15) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
color: #6EE7B7;
|
||||
text-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.status-low::before {
|
||||
background: #10B981;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.6), 0 0 6px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* Button styles with depth and glow */
|
||||
.intel-button {
|
||||
position: relative;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.intel-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.5s, height 0.5s;
|
||||
}
|
||||
|
||||
.intel-button:hover::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.intel-button-primary {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%);
|
||||
border-color: #0EA5E9;
|
||||
color: #38BDF8;
|
||||
text-shadow: 0 0 6px rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
|
||||
.intel-button-primary:hover {
|
||||
background: linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%);
|
||||
box-shadow:
|
||||
0 0 20px rgba(14, 165, 233, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.intel-button-danger {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.1) 100%);
|
||||
border-color: #EF4444;
|
||||
color: #F87171;
|
||||
text-shadow: 0 0 6px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.intel-button-danger:hover {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%);
|
||||
box-shadow:
|
||||
0 0 20px rgba(239, 68, 68, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.intel-button-success {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.1) 100%);
|
||||
border-color: #10B981;
|
||||
color: #34D399;
|
||||
text-shadow: 0 0 6px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.intel-button-success:hover {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%);
|
||||
box-shadow:
|
||||
0 0 20px rgba(16, 185, 129, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Input fields with better contrast */
|
||||
.intel-input {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
color: #F8FAFC;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.2),
|
||||
0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.intel-input:focus {
|
||||
outline: none;
|
||||
border-color: #0EA5E9;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(14, 165, 233, 0.15),
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.15),
|
||||
0 4px 12px rgba(14, 165, 233, 0.1);
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
}
|
||||
|
||||
.intel-input::placeholder {
|
||||
color: rgba(226, 232, 240, 0.35);
|
||||
}
|
||||
|
||||
/* Stat cards with refined depth */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.35);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(14, 165, 233, 0.5);
|
||||
box-shadow:
|
||||
0 8px 20px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||
0 0 24px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #0EA5E9, transparent);
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(14, 165, 233, 0.5);
|
||||
}
|
||||
|
||||
/* Modal overlay with proper backdrop */
|
||||
.modal-overlay {
|
||||
background: rgba(10, 14, 39, 0.97);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Modal card enhancements */
|
||||
.intel-card.modal-card {
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 10px 30px rgba(0, 217, 255, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1E293B;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(14, 165, 233, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(14, 165, 233, 0.5);
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
.fade-in {
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse glow animation */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px currentColor;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Data table styling */
|
||||
.data-row {
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.data-row:hover {
|
||||
background: rgba(0, 217, 255, 0.06);
|
||||
border-bottom-color: rgba(0, 217, 255, 0.3);
|
||||
box-shadow: 0 2px 8px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Vendor entry cards - high contrast and depth */
|
||||
.vendor-card {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.9) 0%, rgba(30, 39, 73, 0.8) 100%);
|
||||
border: 1px solid rgba(0, 217, 255, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.vendor-card:hover {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
border-color: rgba(0, 217, 255, 0.4);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 217, 255, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Document list items with depth */
|
||||
.document-item {
|
||||
background: linear-gradient(135deg, rgba(10, 14, 39, 0.9) 0%, rgba(19, 25, 55, 0.8) 100%);
|
||||
border: 1px solid rgba(0, 217, 255, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
background: linear-gradient(135deg, rgba(10, 14, 39, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
border-color: rgba(0, 217, 255, 0.3);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 217, 255, 0.12),
|
||||
0 2px 6px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* JIRA ticket items with proper contrast */
|
||||
.jira-ticket-item {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.85) 0%, rgba(30, 39, 73, 0.75) 100%);
|
||||
border: 1px solid rgba(255, 184, 0, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.jira-ticket-item:hover {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.85) 100%);
|
||||
border-color: rgba(255, 184, 0, 0.35);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(255, 184, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* CVE Header card with depth */
|
||||
.cve-header {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cve-header:hover {
|
||||
background: linear-gradient(135deg, rgba(30, 39, 73, 0.95) 0%, rgba(42, 52, 88, 0.9) 100%);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
border: 2px solid rgba(14, 165, 233, 0.1);
|
||||
border-top-color: #0EA5E9;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Tooltip with enhanced styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: linear-gradient(135deg, #334155 0%, #475569 100%);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #F8FAFC;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 0 16px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.tooltip:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Enhanced heading glow */
|
||||
h1.text-intel-accent,
|
||||
h2.text-intel-accent,
|
||||
h3.text-intel-accent {
|
||||
text-shadow:
|
||||
0 0 16px rgba(14, 165, 233, 0.3),
|
||||
0 0 32px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
/* Enhanced border glow for featured cards */
|
||||
.border-intel-accent {
|
||||
box-shadow: 0 0 12px rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.border-intel-warning {
|
||||
box-shadow: 0 0 12px rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.border-intel-danger {
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* Quick lookup section enhancement */
|
||||
.quick-lookup-card {
|
||||
background: linear-gradient(135deg, rgba(19, 25, 55, 0.95) 0%, rgba(30, 39, 73, 0.9) 100%);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 0 40px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Vendor Cards - nested depth */
|
||||
.vendor-card {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow:
|
||||
0 3px 10px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.vendor-card:hover {
|
||||
border-color: rgba(14, 165, 233, 0.4);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(14, 165, 233, 0.12),
|
||||
0 3px 10px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Document items - recessed appearance */
|
||||
.document-item {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(20, 28, 48, 0.98) 100%);
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.document-item:hover {
|
||||
border-color: rgba(14, 165, 233, 0.35);
|
||||
background: linear-gradient(135deg, rgba(20, 28, 48, 1) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
||||
0 2px 8px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* Knowledge Base Content Area */
|
||||
.kb-content-area {
|
||||
min-height: 400px;
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Markdown Content Styling */
|
||||
.markdown-content {
|
||||
color: #E2E8F0;
|
||||
line-height: 1.7;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #0EA5E9;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #10B981;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #F59E0B;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #94A3B8;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1rem;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: #0EA5E9;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(14, 165, 233, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: #38BDF8;
|
||||
border-bottom-color: #38BDF8;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #E2E8F0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #0EA5E9;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
background: rgba(14, 165, 233, 0.05);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0EA5E9;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content td {
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.markdown-content tr:hover {
|
||||
background: rgba(14, 165, 233, 0.05);
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(14, 165, 233, 0.2);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-content strong {
|
||||
color: #F8FAFC;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content em {
|
||||
color: #CBD5E1;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
2270
frontend/src/App.js
2270
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@ const ACTION_BADGES = {
|
||||
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_edit: { bg: 'bg-orange-100', text: 'text-orange-800' },
|
||||
cve_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||
};
|
||||
|
||||
const ENTITY_TYPES = ['auth', 'cve', 'document', 'user'];
|
||||
|
||||
167
frontend/src/components/CalendarWidget.js
Normal file
167
frontend/src/components/CalendarWidget.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
function toLocalDateStr(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ onDateClick }) {
|
||||
const today = new Date();
|
||||
const todayStr = toLocalDateStr(today);
|
||||
|
||||
const [calYear, setCalYear] = useState(today.getFullYear());
|
||||
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
||||
|
||||
// Map of "YYYY-MM-DD" → count of findings due that day
|
||||
const [dueDates, setDueDates] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!data?.findings) return;
|
||||
const counts = {};
|
||||
data.findings.forEach((f) => {
|
||||
if (f.dueDate) {
|
||||
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
||||
}
|
||||
});
|
||||
setDueDates(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
||||
else { setCalMonth((m) => m - 1); }
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
||||
else { setCalMonth((m) => m + 1); }
|
||||
};
|
||||
|
||||
// Build cell array: null = padding, number = day of month
|
||||
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const cells = [
|
||||
...Array(firstDow).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
||||
|
||||
const hasDueDatesThisMonth = cells.some((day) => {
|
||||
if (!day) return false;
|
||||
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return !!dueDates[ds];
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Month navigation */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
|
||||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||
{MONTH_NAMES[calMonth]} {calYear}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
>
|
||||
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day-of-week headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||
{cells.map((day, idx) => {
|
||||
if (!day) return <div key={idx} />;
|
||||
|
||||
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isToday = dateStr === todayStr;
|
||||
const dueCount = dueDates[dateStr] || 0;
|
||||
const hasDue = dueCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '2px', padding: '3px 1px',
|
||||
borderRadius: '4px',
|
||||
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||
cursor: hasDue ? 'pointer' : 'default',
|
||||
transition: hasDue ? 'background 0.15s' : undefined,
|
||||
}}
|
||||
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||
}}>
|
||||
{day}
|
||||
</span>
|
||||
{/* Red dot indicator for due dates */}
|
||||
{hasDue ? (
|
||||
<div style={{
|
||||
width: '4px', height: '4px', borderRadius: '50%',
|
||||
background: '#EF4444',
|
||||
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
) : (
|
||||
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend — only shown when there are due dates this month */}
|
||||
{hasDueDatesThisMonth && (
|
||||
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Ivanti finding due
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
@@ -0,0 +1,384 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
const [phase, setPhase] = useState('idle'); // idle, uploading, success, error
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('General');
|
||||
const [result, setResult] = useState(null);
|
||||
const [existingArticles, setExistingArticles] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fetch existing articles on mount
|
||||
useEffect(() => {
|
||||
fetchExistingArticles();
|
||||
}, []);
|
||||
|
||||
const fetchExistingArticles = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||
const data = await response.json();
|
||||
setExistingArticles(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching articles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
const allowedExtensions = ['.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.json', '.yaml', '.yml'];
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
setError('File type not allowed. Please upload: PDF, Markdown, Text, Office docs, or HTML files.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setError('');
|
||||
|
||||
// Auto-populate title from filename if empty
|
||||
if (!title) {
|
||||
const filename = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||
setTitle(filename);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !title.trim()) {
|
||||
setError('Please provide both a title and file');
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase('uploading');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title.trim());
|
||||
formData.append('description', description.trim());
|
||||
formData.append('category', category);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
setPhase('success');
|
||||
|
||||
// Refresh the list of existing articles
|
||||
await fetchExistingArticles();
|
||||
|
||||
// Notify parent to refresh
|
||||
if (onUpdate) onUpdate();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (id, filename) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Error downloading file:', err);
|
||||
setError('Failed to download file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id, articleTitle) => {
|
||||
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Delete failed');
|
||||
|
||||
// Refresh the list
|
||||
await fetchExistingArticles();
|
||||
|
||||
// Notify parent to refresh
|
||||
if (onUpdate) onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error deleting article:', err);
|
||||
setError('Failed to delete article');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setPhase('idle');
|
||||
setSelectedFile(null);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory('General');
|
||||
setResult(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryColor = (cat) => {
|
||||
const colors = {
|
||||
'General': '#94A3B8',
|
||||
'Policy': '#0EA5E9',
|
||||
'Procedure': '#10B981',
|
||||
'Guide': '#F59E0B',
|
||||
'Reference': '#8B5CF6'
|
||||
};
|
||||
return colors[cat] || '#94A3B8';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Knowledge Base</h2>
|
||||
<button onClick={onClose} className="modal-close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="modal-body">
|
||||
{/* Idle Phase - Upload Form */}
|
||||
{phase === 'idle' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Inventory Management Policy"
|
||||
className="intel-input w-full"
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this document..."
|
||||
className="intel-input w-full"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="intel-input w-full"
|
||||
>
|
||||
<option value="General">General</option>
|
||||
<option value="Policy">Policy</option>
|
||||
<option value="Procedure">Procedure</option>
|
||||
<option value="Guide">Guide</option>
|
||||
<option value="Reference">Reference</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Document File *
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||||
onChange={handleFileSelect}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||
Selected: {selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || !title.trim()}
|
||||
className={`intel-button w-full ${selectedFile && title.trim() ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
<UploadIcon className="w-4 h-4 mr-2" />
|
||||
Upload Document
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading Phase */}
|
||||
{phase === 'uploading' && (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Uploading document...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Phase */}
|
||||
{phase === 'success' && result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||
{result.title} has been added to the knowledge base.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Upload Another Document
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Phase */}
|
||||
{phase === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Articles Section */}
|
||||
{(phase === 'idle' || phase === 'success') && existingArticles.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||
Existing Documents ({existingArticles.length})
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{existingArticles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="intel-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="w-4 h-4 flex-shrink-0" style={{ color: getCategoryColor(article.category) }} />
|
||||
<p className="font-medium truncate" style={{ color: '#E2E8F0' }}>
|
||||
{article.title}
|
||||
</p>
|
||||
</div>
|
||||
{article.description && (
|
||||
<p className="text-sm mb-2 line-clamp-2" style={{ color: '#94A3B8' }}>
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: `${getCategoryColor(article.category)}33`,
|
||||
color: getCategoryColor(article.category)
|
||||
}}
|
||||
>
|
||||
{article.category}
|
||||
</span>
|
||||
<span>{formatDate(article.created_at)}</span>
|
||||
<span>{formatFileSize(article.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownload(article.id, article.file_name)}
|
||||
className="intel-button intel-button-small intel-button-success"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(article.id, article.title)}
|
||||
className="intel-button intel-button-small"
|
||||
style={{ borderColor: '#EF4444', color: '#EF4444' }}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
248
frontend/src/components/KnowledgeBaseViewer.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticleContent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article.id]);
|
||||
|
||||
const fetchArticleContent = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/content`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch article content');
|
||||
|
||||
const text = await response.text();
|
||||
setContent(text);
|
||||
} catch (err) {
|
||||
console.error('Error fetching article content:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/download`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = article.file_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Error downloading file:', err);
|
||||
setError('Failed to download file');
|
||||
}
|
||||
};
|
||||
|
||||
const isMarkdown = article.file_name?.endsWith('.md');
|
||||
const isText = article.file_name?.endsWith('.txt');
|
||||
const isPDF = article.file_name?.endsWith('.pdf');
|
||||
const isImage = /\.(jpg|jpeg|png|gif|bmp)$/i.test(article.file_name || '');
|
||||
|
||||
const getCategoryColor = (cat) => {
|
||||
const colors = {
|
||||
'General': '#94A3B8',
|
||||
'Policy': '#0EA5E9',
|
||||
'Procedure': '#10B981',
|
||||
'Guide': '#F59E0B',
|
||||
'Reference': '#8B5CF6'
|
||||
};
|
||||
return colors[cat] || '#94A3B8';
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
|
||||
padding: '1.5rem',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
|
||||
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
|
||||
{article.title}
|
||||
</h2>
|
||||
</div>
|
||||
{article.description && (
|
||||
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||
<span
|
||||
className="px-2 py-1 rounded"
|
||||
style={{
|
||||
background: `${getCategoryColor(article.category)}33`,
|
||||
color: getCategoryColor(article.category),
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
{article.category}
|
||||
</span>
|
||||
<span>Created: {formatDate(article.created_at)}</span>
|
||||
{article.created_by_username && (
|
||||
<span>By: {article.created_by_username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="intel-button intel-button-small"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="intel-button intel-button-small"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="kb-content-area">
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Loading document...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Failed to Load Document</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Markdown Rendering */}
|
||||
{isMarkdown && (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plain Text */}
|
||||
{isText && !isMarkdown && (
|
||||
<pre
|
||||
className="text-sm p-4 rounded overflow-auto"
|
||||
style={{
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
color: '#E2E8F0',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
maxHeight: '600px'
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* PDF */}
|
||||
{isPDF && (
|
||||
<div className="w-full" style={{ height: '700px' }}>
|
||||
<iframe
|
||||
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||
title={article.title}
|
||||
className="w-full h-full rounded"
|
||||
style={{
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
background: 'rgba(15, 23, 42, 0.8)'
|
||||
}}
|
||||
>
|
||||
<div className="text-center py-12">
|
||||
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} />
|
||||
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||
Your browser doesn't support PDF preview. Click the download button to view this file.
|
||||
</p>
|
||||
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
</iframe>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{isImage && (
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||
alt={article.title}
|
||||
className="max-w-full h-auto rounded"
|
||||
style={{ border: '1px solid rgba(14, 165, 233, 0.3)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other file types */}
|
||||
{!isMarkdown && !isText && !isPDF && !isImage && (
|
||||
<div className="text-center py-12">
|
||||
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#94A3B8' }} />
|
||||
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||
Preview not available for this file type.
|
||||
</p>
|
||||
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader, AlertCircle, Lock, User } from 'lucide-react';
|
||||
import { AlertCircle, Lock, User } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function LoginForm() {
|
||||
@@ -24,57 +24,60 @@ export default function LoginForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-8">
|
||||
<div className="min-h-screen bg-intel-darkest grid-bg flex items-center justify-center p-4 relative overflow-hidden fade-in">
|
||||
{/* Scanning line effect */}
|
||||
<div className="scan-line"></div>
|
||||
|
||||
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#0476D9] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-intel-accent to-intel-accent-dim rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg" style={{boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)'}}>
|
||||
<Lock className="w-8 h-8 text-intel-darkest" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">CVE Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to access the dashboard</p>
|
||||
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">CVE INTEL</h1>
|
||||
<p className="text-gray-400 mt-2 font-sans text-sm">Threat Intelligence Access Portal</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
<div className="mb-6 p-4 bg-intel-danger/10 border border-intel-danger/30 rounded flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-intel-danger flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-gray-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label htmlFor="username" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<User className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
className="intel-input w-full pl-10"
|
||||
placeholder="Enter username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label htmlFor="password" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<Lock className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
className="intel-input w-full pl-10"
|
||||
placeholder="Enter password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
@@ -83,22 +86,22 @@ export default function LoginForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
className="w-full intel-button intel-button-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Signing in...
|
||||
<div className="loading-spinner w-5 h-5"></div>
|
||||
<span className="font-mono uppercase tracking-wider">Authenticating...</span>
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
<span className="font-mono uppercase tracking-wider">Access System</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Default admin credentials: admin / admin123
|
||||
<div className="mt-6 pt-6 border-t border-intel-grid">
|
||||
<p className="text-sm text-gray-500 text-center font-mono">
|
||||
Default: <span className="text-intel-accent">admin</span> / <span className="text-intel-accent">admin123</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
128
frontend/src/components/NavDrawer.js
Normal file
128
frontend/src/components/NavDrawer.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
];
|
||||
|
||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.65)',
|
||||
backdropFilter: 'blur(3px)',
|
||||
zIndex: 50
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 51,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
padding: '1.5rem'
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||
STEAM
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||
Security Dashboard
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||
>
|
||||
<X style={{ width: '20px', height: '20px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { onNavigate(id); onClose(); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||
padding: '0.75rem 0.875rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||
background: active ? `${color}18` : 'transparent',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
transition: 'background 0.15s, border-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Icon box */}
|
||||
<div style={{
|
||||
width: '36px', height: '36px', flexShrink: 0,
|
||||
borderRadius: '0.375rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}40`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||
</div>
|
||||
|
||||
{/* Label + description */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
color: active ? color : '#CBD5E1',
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active indicator dot */}
|
||||
{active && (
|
||||
<div style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
marginTop: 'auto', paddingTop: '1rem',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
NTS Threat Intelligence
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
509
frontend/src/components/NvdSyncModal.js
Normal file
509
frontend/src/components/NvdSyncModal.js
Normal file
@@ -0,0 +1,509 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const FETCH_DELAY_MS = 7000; // 7 seconds between requests (safe for 5 req/30s without API key)
|
||||
const RETRY_DELAY_MS = 35000; // Wait 35 seconds on 429 before retry
|
||||
|
||||
export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
const [phase, setPhase] = useState('idle'); // idle, fetching, review, applying, done
|
||||
const [cveIds, setCveIds] = useState([]);
|
||||
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0, currentId: '' });
|
||||
const [results, setResults] = useState({}); // { cveId: { nvd: {...}, current: {...}, status: 'found'|'not_found'|'error'|'no_change', error: '' } }
|
||||
const [descriptionChoices, setDescriptionChoices] = useState({}); // { cveId: 'keep' | 'nvd' }
|
||||
const [applyResult, setApplyResult] = useState(null);
|
||||
const [expandedDesc, setExpandedDesc] = useState({});
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// Fetch distinct CVE IDs on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves/distinct-ids`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch CVE list');
|
||||
const data = await response.json();
|
||||
setCveIds(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching CVE IDs:', err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const fetchNvdData = async () => {
|
||||
setPhase('fetching');
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const newResults = {};
|
||||
setFetchProgress({ current: 0, total: cveIds.length, currentId: '' });
|
||||
|
||||
// First fetch current data for all CVEs
|
||||
let currentData = {};
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves`, { credentials: 'include', signal: controller.signal });
|
||||
if (response.ok) {
|
||||
const allCves = await response.json();
|
||||
// Group by cve_id, take first entry for description/severity/date
|
||||
allCves.forEach(cve => {
|
||||
if (!currentData[cve.cve_id]) {
|
||||
currentData[cve.cve_id] = {
|
||||
description: cve.description,
|
||||
severity: cve.severity,
|
||||
published_date: cve.published_date
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') { setPhase('idle'); return; }
|
||||
}
|
||||
|
||||
for (let i = 0; i < cveIds.length; i++) {
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
const cveId = cveIds[i];
|
||||
setFetchProgress({ current: i + 1, total: cveIds.length, currentId: cveId });
|
||||
|
||||
try {
|
||||
let response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
// Handle rate limit with one retry
|
||||
if (response.status === 429) {
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
if (controller.signal.aborted) break;
|
||||
response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
newResults[cveId] = { status: 'not_found', current: currentData[cveId] || {} };
|
||||
} else if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
newResults[cveId] = { status: 'error', error: data.error || `HTTP ${response.status}`, current: currentData[cveId] || {} };
|
||||
} else {
|
||||
const nvd = await response.json();
|
||||
const current = currentData[cveId] || {};
|
||||
|
||||
const descChanged = nvd.description && nvd.description !== current.description;
|
||||
const sevChanged = nvd.severity && nvd.severity !== current.severity;
|
||||
const dateChanged = nvd.published_date && nvd.published_date !== current.published_date;
|
||||
|
||||
if (!descChanged && !sevChanged && !dateChanged) {
|
||||
newResults[cveId] = { status: 'no_change', nvd, current };
|
||||
} else {
|
||||
newResults[cveId] = { status: 'found', nvd, current, descChanged, sevChanged, dateChanged };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') break;
|
||||
newResults[cveId] = { status: 'error', error: err.message, current: currentData[cveId] || {} };
|
||||
}
|
||||
|
||||
// Update results progressively
|
||||
setResults({ ...newResults });
|
||||
|
||||
// Rate limit delay (skip after last item)
|
||||
if (i < cveIds.length - 1 && !controller.signal.aborted) {
|
||||
await sleep(FETCH_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
setResults({ ...newResults });
|
||||
// Default all description choices to 'keep'
|
||||
const choices = {};
|
||||
Object.entries(newResults).forEach(([id, r]) => {
|
||||
if (r.status === 'found' && r.descChanged) {
|
||||
choices[id] = 'keep';
|
||||
}
|
||||
});
|
||||
setDescriptionChoices(choices);
|
||||
setPhase('review');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelFetch = () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
setPhase('idle');
|
||||
};
|
||||
|
||||
const setBulkDescriptionChoice = (choice) => {
|
||||
const newChoices = {};
|
||||
Object.keys(descriptionChoices).forEach(id => {
|
||||
newChoices[id] = choice;
|
||||
});
|
||||
setDescriptionChoices(newChoices);
|
||||
};
|
||||
|
||||
const getChangesCount = () => {
|
||||
let count = 0;
|
||||
Object.entries(results).forEach(([id, r]) => {
|
||||
if (r.status === 'found') {
|
||||
if (r.sevChanged || r.dateChanged || (r.descChanged && descriptionChoices[id] === 'nvd')) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
const applyChanges = async () => {
|
||||
setPhase('applying');
|
||||
const updates = [];
|
||||
|
||||
Object.entries(results).forEach(([cveId, r]) => {
|
||||
if (r.status !== 'found') return;
|
||||
|
||||
const update = { cve_id: cveId };
|
||||
let hasChange = false;
|
||||
|
||||
if (r.sevChanged) {
|
||||
update.severity = r.nvd.severity;
|
||||
hasChange = true;
|
||||
}
|
||||
if (r.dateChanged) {
|
||||
update.published_date = r.nvd.published_date;
|
||||
hasChange = true;
|
||||
}
|
||||
if (r.descChanged && descriptionChoices[cveId] === 'nvd') {
|
||||
update.description = r.nvd.description;
|
||||
hasChange = true;
|
||||
}
|
||||
|
||||
if (hasChange) updates.push(update);
|
||||
});
|
||||
|
||||
if (updates.length === 0) {
|
||||
setApplyResult({ updated: 0, message: 'No changes to apply' });
|
||||
setPhase('done');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cves/nvd-sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ updates })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Sync failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setApplyResult(data);
|
||||
onSyncComplete();
|
||||
} catch (err) {
|
||||
setApplyResult({ error: err.message });
|
||||
}
|
||||
setPhase('done');
|
||||
};
|
||||
|
||||
const truncate = (str, len = 120) => str && str.length > len ? str.substring(0, len) + '...' : str;
|
||||
|
||||
// Summary counts
|
||||
const foundCount = Object.values(results).filter(r => r.status === 'found').length;
|
||||
const noChangeCount = Object.values(results).filter(r => r.status === 'no_change').length;
|
||||
const notFoundCount = Object.values(results).filter(r => r.status === 'not_found').length;
|
||||
const errorCount = Object.values(results).filter(r => r.status === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<RefreshCw className="w-6 h-6 text-green-600" />
|
||||
Sync with NVD
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Update existing CVE entries with data from the National Vulnerability Database</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{/* Idle Phase */}
|
||||
{phase === 'idle' && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-lg text-gray-700 mb-2">
|
||||
{cveIds.length > 0
|
||||
? <><strong>{cveIds.length}</strong> unique CVE{cveIds.length !== 1 ? 's' : ''} in database</>
|
||||
: 'Loading CVE count...'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
This will fetch data from NVD for each CVE and let you review changes before applying.
|
||||
Rate-limited to stay within NVD API limits.
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchNvdData}
|
||||
disabled={cveIds.length === 0}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50 flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Fetch NVD Data
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fetching Phase */}
|
||||
{phase === 'fetching' && (
|
||||
<div className="py-8">
|
||||
<div className="text-center mb-6">
|
||||
<Loader className="w-8 h-8 text-green-600 animate-spin mx-auto mb-3" />
|
||||
<p className="text-lg text-gray-700">
|
||||
Fetching CVE {fetchProgress.current} of {fetchProgress.total}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 font-mono mt-1">{fetchProgress.currentId}</p>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-4">
|
||||
<div
|
||||
className="bg-green-600 h-3 rounded-full transition-all duration-300"
|
||||
style={{ width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={cancelFetch}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Phase */}
|
||||
{phase === 'review' && (
|
||||
<div>
|
||||
{/* Summary bar */}
|
||||
<div className="flex flex-wrap gap-3 mb-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="font-medium">Found: <span className="text-green-700">{foundCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Up to date: <span className="text-gray-600">{noChangeCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Changes: <span className="text-blue-700">{foundCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Not in NVD: <span className="text-gray-400">{notFoundCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Errors: <span className="text-red-600">{errorCount}</span></span>
|
||||
</div>
|
||||
|
||||
{/* Bulk controls */}
|
||||
{Object.keys(descriptionChoices).length > 0 && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<span className="text-sm text-gray-600 self-center">Descriptions:</span>
|
||||
<button
|
||||
onClick={() => setBulkDescriptionChoice('keep')}
|
||||
className="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Keep All Existing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBulkDescriptionChoice('nvd')}
|
||||
className="px-3 py-1 text-xs rounded border border-green-300 text-green-700 hover:bg-green-50 transition-colors"
|
||||
>
|
||||
Use All NVD
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comparison table */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(results).map(([cveId, r]) => {
|
||||
if (r.status === 'no_change') {
|
||||
return (
|
||||
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="text-gray-400">—</span>
|
||||
<span className="font-mono font-medium text-gray-500">{cveId}</span>
|
||||
<span className="text-gray-400">No changes needed</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (r.status === 'not_found') {
|
||||
return (
|
||||
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="text-gray-400">—</span>
|
||||
<span className="font-mono font-medium text-gray-400">{cveId}</span>
|
||||
<span className="text-gray-400 italic">Not found in NVD</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (r.status === 'error') {
|
||||
return (
|
||||
<div key={cveId} className="flex items-center gap-3 p-3 bg-red-50 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||
<span className="font-mono font-medium text-gray-700">{cveId}</span>
|
||||
<span className="text-red-600">{r.error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// status === 'found' — show changes
|
||||
const isExpanded = expandedDesc[cveId];
|
||||
return (
|
||||
<div key={cveId} className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<span className="font-mono font-bold text-gray-900">{cveId}</span>
|
||||
{r.sevChanged && (
|
||||
<span className="text-xs">
|
||||
Severity: <span className="text-red-600">{r.current.severity}</span>
|
||||
{' → '}
|
||||
<span className="text-green-700">{r.nvd.severity}</span>
|
||||
</span>
|
||||
)}
|
||||
{r.dateChanged && (
|
||||
<span className="text-xs">
|
||||
Date: <span className="text-red-600">{r.current.published_date || '(none)'}</span>
|
||||
{' → '}
|
||||
<span className="text-green-700">{r.nvd.published_date}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{r.descChanged && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">Description:</span>
|
||||
<button
|
||||
onClick={() => setExpandedDesc(prev => ({ ...prev, [cveId]: !prev[cveId] }))}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="p-2 bg-red-50 rounded border border-red-200">
|
||||
<span className="font-medium text-red-700">Current: </span>
|
||||
<span className="text-gray-700">{r.current.description || '(empty)'}</span>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 rounded border border-green-200">
|
||||
<span className="font-medium text-green-700">NVD: </span>
|
||||
<span className="text-gray-700">{r.nvd.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">{truncate(r.nvd.description)}</p>
|
||||
)}
|
||||
|
||||
{/* Description choice */}
|
||||
<div className="flex gap-4 mt-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`desc-${cveId}`}
|
||||
checked={descriptionChoices[cveId] === 'keep'}
|
||||
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'keep' }))}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
Keep existing
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`desc-${cveId}`}
|
||||
checked={descriptionChoices[cveId] === 'nvd'}
|
||||
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'nvd' }))}
|
||||
className="text-green-600"
|
||||
/>
|
||||
Use NVD
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applying Phase */}
|
||||
{phase === 'applying' && (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-10 h-10 text-green-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-lg text-gray-700">Applying changes...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done Phase */}
|
||||
{phase === 'done' && applyResult && (
|
||||
<div className="text-center py-8">
|
||||
{applyResult.error ? (
|
||||
<>
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-lg text-red-700 font-medium mb-2">Sync failed</p>
|
||||
<p className="text-sm text-gray-600">{applyResult.error}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||
<p className="text-lg text-green-700 font-medium mb-2">Sync complete</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated
|
||||
</p>
|
||||
{applyResult.errors && applyResult.errors.length > 0 && (
|
||||
<p className="text-sm text-amber-600 mt-2">
|
||||
{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||
{phase === 'review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={applyChanges}
|
||||
disabled={getChangesCount() === 0}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50"
|
||||
>
|
||||
Apply {getChangesCount()} Change{getChangesCount() !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === 'done' && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
@@ -0,0 +1,424 @@
|
||||
// ComplianceChartsPanel.js
|
||||
// Tier-1 time-based compliance charts using Recharts.
|
||||
// Charts rendered: Active Findings Over Time, Change per Cycle,
|
||||
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const TEAL = '#14B8A6';
|
||||
|
||||
const TEAM_COLORS = {
|
||||
'STEAM': '#0EA5E9',
|
||||
'ACCESS-ENG': '#F59E0B',
|
||||
'ACCESS-OPS': '#8B5CF6',
|
||||
'INTELDEV': '#10B981',
|
||||
};
|
||||
|
||||
const ARCHER_STATUS_COLORS = {
|
||||
'Draft': '#475569',
|
||||
'Open': '#0EA5E9',
|
||||
'Under Review': '#F59E0B',
|
||||
'Accepted': '#10B981',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared style tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom dark tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
function DarkTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(10,17,32,0.97)',
|
||||
border: '1px solid rgba(20,184,166,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '130px',
|
||||
}}>
|
||||
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||
{label}
|
||||
</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||
<span style={{ fontWeight: '700' }}>
|
||||
{typeof p.value === 'number'
|
||||
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
|
||||
: p.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart card wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
function ChartCard({ title, subtitle, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem 1.125rem 0.875rem',
|
||||
}}>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||||
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty / no-data state
|
||||
// ---------------------------------------------------------------------------
|
||||
function NoData({ msg }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '160px', color: '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
border: '1px dashed rgba(20,184,166,0.1)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
{msg || 'No data yet — upload compliance reports to populate this chart'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shorten a YYYY-MM-DD string to MM/DD/YY
|
||||
// ---------------------------------------------------------------------------
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
const p = d.split('-');
|
||||
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart 1 — Active Findings Over Time (line, total + per team)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ActiveTrendChart({ data }) {
|
||||
if (data.length < 2) return <NoData />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||
<Line
|
||||
type="monotone" dataKey="total_active" name="Total"
|
||||
stroke={TEAL} strokeWidth={2}
|
||||
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
||||
<Line
|
||||
key={team}
|
||||
type="monotone" dataKey={team} name={team}
|
||||
stroke={color} strokeWidth={1.5}
|
||||
dot={false} strokeDasharray="5 3"
|
||||
activeDot={{ r: 4, fill: color }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
function DeltaChart({ data }) {
|
||||
if (data.length === 0) return <NoData />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
|
||||
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
|
||||
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart 3 — Team Health Multi-Line
|
||||
// ---------------------------------------------------------------------------
|
||||
function TeamTrendChart({ data }) {
|
||||
if (data.length < 2) return <NoData />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
||||
<Line
|
||||
key={team}
|
||||
type="monotone" dataKey={team} name={team}
|
||||
stroke={color} strokeWidth={2}
|
||||
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart 4 — MTTR by Team (horizontal bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MttrChart({ data }) {
|
||||
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
|
||||
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
|
||||
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
|
||||
// ---------------------------------------------------------------------------
|
||||
function RecurringChart({ data }) {
|
||||
if (data.length === 0) return <NoData />;
|
||||
const top10 = data.slice(0, 10).map(r => ({
|
||||
...r,
|
||||
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
|
||||
}));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
|
||||
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
|
||||
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
|
||||
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
|
||||
]} />
|
||||
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
|
||||
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ArcherPipelineChart({ data }) {
|
||||
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
|
||||
<Bar
|
||||
key={status}
|
||||
dataKey={status} name={status} stackId="s"
|
||||
fill={color} fillOpacity={0.85}
|
||||
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main panel
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ComplianceChartsPanel() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [trends, setTrends] = useState([]);
|
||||
const [mttr, setMttr] = useState([]);
|
||||
const [recurring, setRecurring] = useState([]);
|
||||
const [archerRaw, setArcherRaw] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tRes, mRes, rRes, aRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
|
||||
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
|
||||
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
|
||||
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
|
||||
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
|
||||
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
|
||||
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
|
||||
} catch { /* silent — charts will show no-data state */ }
|
||||
finally { if (!cancelled) setLoading(false); }
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Format trend rows — add short date label
|
||||
const formattedTrends = useMemo(
|
||||
() => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })),
|
||||
[trends]
|
||||
);
|
||||
|
||||
// Pivot archer raw rows → one object per date
|
||||
const archerByDate = useMemo(() => {
|
||||
if (!archerRaw.length) return [];
|
||||
const map = {};
|
||||
archerRaw.forEach(r => {
|
||||
if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) };
|
||||
map[r.date][r.status] = r.count;
|
||||
});
|
||||
return Object.values(map).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [archerRaw]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
|
||||
{/* ── Section header / collapse toggle ──────────────────────── */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '0 0 0.625rem 0',
|
||||
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
|
||||
marginBottom: collapsed ? 0 : '0.875rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
}}>
|
||||
Historical Trends
|
||||
</span>
|
||||
{loading && (
|
||||
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||
)}
|
||||
</div>
|
||||
{collapsed
|
||||
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||
}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}>
|
||||
|
||||
{/* 1. Active findings over time */}
|
||||
<ChartCard
|
||||
title="Active Findings Over Time"
|
||||
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
|
||||
>
|
||||
<ActiveTrendChart data={formattedTrends} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 2. New / Recurring / Resolved delta per cycle */}
|
||||
<ChartCard
|
||||
title="Change per Report Cycle"
|
||||
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
|
||||
>
|
||||
<DeltaChart data={formattedTrends} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 3. Team health multi-line */}
|
||||
<ChartCard
|
||||
title="Team Compliance Health"
|
||||
subtitle="Active findings per team per cycle — lower is better"
|
||||
>
|
||||
<TeamTrendChart data={formattedTrends} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 4. MTTR per team */}
|
||||
<ChartCard
|
||||
title="Mean Time to Resolution"
|
||||
subtitle="Average calendar days between first-seen and resolved, by team"
|
||||
>
|
||||
<MttrChart data={mttr} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 5. Most persistent / recurring findings */}
|
||||
<ChartCard
|
||||
title="Most Persistent Findings"
|
||||
subtitle="Active items with the highest recurrence count (top 10)"
|
||||
>
|
||||
<RecurringChart data={recurring} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 6. Archer ticket pipeline */}
|
||||
<ChartCard
|
||||
title="Archer Exception Pipeline"
|
||||
subtitle="Exception ticket status distribution by creation date"
|
||||
>
|
||||
<ArcherPipelineChart data={archerByDate} />
|
||||
</ChartCard>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
frontend/src/components/pages/ComplianceDetailPanel.js
Normal file
366
frontend/src/components/pages/ComplianceDetailPanel.js
Normal file
@@ -0,0 +1,366 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const TEAL = '#14B8A6';
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
'Vulnerability Management': '#EF4444',
|
||||
'Access & MFA': '#F59E0B',
|
||||
'Logging & Monitoring': '#8B5CF6',
|
||||
'End-of-Life OS': '#F97316',
|
||||
'Decommissioned Assets': '#64748B',
|
||||
'Asset Data Quality': '#64748B',
|
||||
'Application Security': '#0EA5E9',
|
||||
'Disaster Recovery': TEAL,
|
||||
'Endpoint Protection': '#F97316',
|
||||
};
|
||||
|
||||
function categoryColor(category) {
|
||||
return CATEGORY_COLORS[category] || '#94A3B8';
|
||||
}
|
||||
|
||||
function MetricChip({ metricId, category, status }) {
|
||||
const color = status === 'resolved' ? '#64748B' : categoryColor(category);
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.2rem 0.5rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}50`,
|
||||
borderRadius: '0.25rem',
|
||||
color, fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
}}>
|
||||
{metricId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [noteMetric, setNoteMetric] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [noteError, setNoteError] = useState(null);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||
setDetail(data);
|
||||
|
||||
// Default note metric to first active failing metric
|
||||
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||
if (firstActive) setNoteMetric(firstActive.metric_id);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostname]);
|
||||
|
||||
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!noteText.trim() || !noteMetric) return;
|
||||
setSubmitting(true);
|
||||
setNoteError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/notes`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save note');
|
||||
setNoteText('');
|
||||
await fetchDetail();
|
||||
if (onNoteAdded) onNoteAdded();
|
||||
} catch (err) {
|
||||
setNoteError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
|
||||
const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 40 }} />
|
||||
|
||||
{/* Panel */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
borderLeft: `1px solid ${TEAL}30`,
|
||||
boxShadow: `-8px 0 32px rgba(0,0,0,0.6)`,
|
||||
zIndex: 41,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '1.25rem 1.25rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||
{hostname}
|
||||
</div>
|
||||
{detail && (
|
||||
<div style={{ marginTop: '0.4rem', display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{detail.ip_address && (
|
||||
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: '#64748B' }}>{detail.ip_address}</span>
|
||||
)}
|
||||
{detail.device_type && (
|
||||
<span style={{ fontSize: '0.72rem', color: '#475569' }}>· {detail.device_type}</span>
|
||||
)}
|
||||
<span style={{ fontSize: '0.72rem', color: TEAL }}>· {detail.team}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '1.25rem', display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0, marginTop: '1px' }} />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && detail && (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Active failing metrics */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="Failing Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.map(m => (
|
||||
<MetricRow key={m.metric_id} metric={m} onNavigate={onNavigate} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Resolved metrics */}
|
||||
{resolvedMetrics.length > 0 && (
|
||||
<Section title="Resolved Metrics" muted>
|
||||
{resolvedMetrics.map(m => (
|
||||
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Upload history summary */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.map(m => (
|
||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||
{m.seen_count}× seen
|
||||
</span>
|
||||
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||
{detail.notes.length === 0 && (
|
||||
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
|
||||
)}
|
||||
{detail.notes.map(n => (
|
||||
<div key={n.id} style={{
|
||||
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
|
||||
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add note */}
|
||||
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
{activeMetrics.length > 1 && (
|
||||
<select
|
||||
value={noteMetric}
|
||||
onChange={e => setNoteMetric(e.target.value)}
|
||||
style={{
|
||||
width: '100%', marginBottom: '0.5rem',
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.25rem', color: '#CBD5E1',
|
||||
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||
}}>
|
||||
{activeMetrics.map(m => (
|
||||
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} — {m.category}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<textarea
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
placeholder="Add a note…"
|
||||
rows={2}
|
||||
style={{
|
||||
flex: 1, resize: 'none',
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.375rem', color: '#F8FAFC',
|
||||
padding: '0.5rem 0.625rem', fontSize: '0.8rem',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
|
||||
/>
|
||||
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
|
||||
style={{
|
||||
padding: '0.5rem 0.625rem', flexShrink: 0,
|
||||
background: noteText.trim() ? `${TEAL}20` : 'transparent',
|
||||
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||||
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
|
||||
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
|
||||
}}>
|
||||
{submitting
|
||||
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
|
||||
: <Send style={{ width: '16px', height: '16px' }} />}
|
||||
</button>
|
||||
</div>
|
||||
{noteError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem' }}>{noteError}</div>}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, icon, children, muted, grow }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '1rem 1.25rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
...(grow ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}),
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: muted ? '#334155' : '#475569',
|
||||
marginBottom: '0.75rem',
|
||||
}}>
|
||||
{icon && <span style={{ color: muted ? '#334155' : TEAL }}>{icon}</span>}
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({ metric, resolved, onNavigate }) {
|
||||
const color = resolved ? '#475569' : categoryColor(metric.category);
|
||||
const extra = metric.extra || {};
|
||||
|
||||
const ivantiId = (!resolved && metric.metric_id?.startsWith('2.3'))
|
||||
? (extra['Ivanti_Vulnerability_ID'] || null)
|
||||
: null;
|
||||
|
||||
// Surface the most useful extra fields per metric type
|
||||
const highlights = [];
|
||||
if (extra['CVEs_Associated']) highlights.push({ label: 'CVEs', value: extra['CVEs_Associated'] });
|
||||
if (extra['SLA_Status']) highlights.push({ label: 'SLA', value: extra['SLA_Status'] });
|
||||
if (extra['Due_Date']) highlights.push({ label: 'Due', value: extra['Due_Date'] });
|
||||
if (extra['Normalized - Operating System'])
|
||||
highlights.push({ label: 'OS', value: `${extra['Normalized - Operating System']} ${extra['Normalized - Operating System Version'] || ''}`.trim() });
|
||||
if (extra['EOS - End of Service Life'])
|
||||
highlights.push({ label: 'EoL', value: extra['EOS - End of Service Life'] });
|
||||
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
|
||||
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||
background: resolved ? 'transparent' : `${color}08`,
|
||||
border: `1px solid ${color}25`,
|
||||
borderRadius: '0.375rem',
|
||||
opacity: resolved ? 0.5 : 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
|
||||
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
|
||||
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
|
||||
</div>
|
||||
{metric.metric_desc && (
|
||||
<div style={{ fontSize: '0.72rem', color: '#475569', marginBottom: (highlights.length || ivantiId) ? '0.4rem' : 0, lineHeight: 1.4 }}>
|
||||
{metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
|
||||
</div>
|
||||
)}
|
||||
{ivantiId && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: highlights.length ? '0.25rem' : 0 }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', minWidth: 0 }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>Ivanti ID</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
|
||||
</div>
|
||||
{onNavigate && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||||
style={{
|
||||
flexShrink: 0, marginLeft: '0.5rem',
|
||||
background: 'rgba(14,165,233,0.1)',
|
||||
border: '1px solid rgba(14,165,233,0.4)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#0EA5E9',
|
||||
fontSize: '0.65rem', fontFamily: 'monospace',
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||||
>
|
||||
View in Triage →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{highlights.map(h => (
|
||||
<div key={h.label} style={{ display: 'flex', gap: '0.4rem', marginTop: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', minWidth: '48px' }}>{h.label}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
{String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
499
frontend/src/components/pages/CompliancePage.js
Normal file
499
frontend/src/components/pages/CompliancePage.js
Normal file
@@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const TEAMS = ['STEAM', 'ACCESS-ENG'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR = {
|
||||
'Meets/Exceeds Target': '#10B981',
|
||||
'Within 15% of Target': '#F59E0B',
|
||||
'Below 15% of Target': '#EF4444',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
'Vulnerability Management': '#EF4444',
|
||||
'Access & MFA': '#F59E0B',
|
||||
'Logging & Monitoring': '#8B5CF6',
|
||||
'End-of-Life OS': '#F97316',
|
||||
'Decommissioned Assets': '#64748B',
|
||||
'Asset Data Quality': '#64748B',
|
||||
'Application Security': '#0EA5E9',
|
||||
'Disaster Recovery': TEAL,
|
||||
'Endpoint Protection': '#F97316',
|
||||
};
|
||||
|
||||
function statusColor(status) {
|
||||
return STATUS_COLOR[status] || '#EF4444';
|
||||
}
|
||||
|
||||
function pctDisplay(pct) {
|
||||
return `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
|
||||
// Deduplicate summary entries — one per metric_id for the selected team
|
||||
// (exclude aggregate "ALL: NTS-AEO" rows)
|
||||
function teamMetrics(entries, team) {
|
||||
return entries.filter(e => e.team === team);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricHealthCard({ entry, active, onClick }) {
|
||||
const color = statusColor(entry.status);
|
||||
const isOk = entry.status === 'Meets/Exceeds Target';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: active
|
||||
? `${color}18`
|
||||
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: `1.5px solid ${active ? color : color + '40'}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.875rem 1rem',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s',
|
||||
minWidth: '160px',
|
||||
flex: '1 1 0',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = color + '40'; }}
|
||||
>
|
||||
{/* Metric ID */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
|
||||
{entry.metric_id}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{entry.category}
|
||||
</div>
|
||||
|
||||
{/* Compliance % */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
|
||||
{pctDisplay(entry.compliance_pct)}
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
target {pctDisplay(entry.target)}
|
||||
</div>
|
||||
|
||||
{/* Status pill */}
|
||||
<div style={{
|
||||
marginTop: '0.625rem', display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
color, padding: '0.2rem 0.5rem',
|
||||
background: `${color}12`, borderRadius: '999px',
|
||||
border: `1px solid ${color}30`,
|
||||
}}>
|
||||
<span style={{
|
||||
width: '5px', height: '5px', borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||||
}} />
|
||||
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricBadge({ metricId, category }) {
|
||||
const color = CATEGORY_COLORS[category] || '#94A3B8';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.15rem 0.45rem',
|
||||
background: `${color}15`, border: `1px solid ${color}40`,
|
||||
borderRadius: '0.2rem', color,
|
||||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{metricId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SeenBadge({ count }) {
|
||||
const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '700',
|
||||
color, padding: '0.15rem 0.4rem',
|
||||
background: `${color}12`, borderRadius: '0.2rem',
|
||||
border: `1px solid ${color}30`, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{count}×
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CompliancePage({ onNavigate }) {
|
||||
const { canWrite } = useAuth();
|
||||
|
||||
const [activeTeam, setActiveTeam] = useState('STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedHost, setSelectedHost] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
const fetchSummary = useCallback(async (team) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/summary?team=${team}`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
setSummary(data);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const fetchDevices = useCallback(async (team, tab) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/items?team=${team}&status=${tab}`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load');
|
||||
setDevices(data.devices || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDevices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
setHostSearch('');
|
||||
setSelectedHost(null);
|
||||
fetchSummary(activeTeam);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const refresh = () => {
|
||||
fetchSummary(activeTeam);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
};
|
||||
|
||||
// In-memory filters
|
||||
const filteredDevices = devices
|
||||
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
|
||||
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
||||
|
||||
const metrics = teamMetrics(summary.entries, activeTeam);
|
||||
const lastUpload = summary.upload;
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: '2rem' }}>
|
||||
|
||||
{/* ── Page header ─────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||
color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 16px ${TEAL}40`, marginBottom: '0.25rem',
|
||||
}}>
|
||||
AEO Compliance
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{lastUpload ? (
|
||||
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
|
||||
)}
|
||||
{summary.overall_scores?.customer_network != null && (
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||
Network: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.customer_network)}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.overall_scores?.vertical != null && (
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||
Vertical: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.vertical)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<button onClick={refresh} title="Refresh"
|
||||
style={{ background: 'none', border: '1px solid rgba(20,184,166,0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
|
||||
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<button onClick={() => setShowUpload(true)}
|
||||
className="intel-button"
|
||||
style={{
|
||||
background: `${TEAL}18`, border: `1px solid ${TEAL}`,
|
||||
color: TEAL, padding: '0.5rem 1rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<Upload style={{ width: '14px', height: '14px' }} />
|
||||
Upload Report
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||
{TEAMS.map(team => {
|
||||
const isActive = activeTeam === team;
|
||||
return (
|
||||
<button key={team} onClick={() => setActiveTeam(team)}
|
||||
style={{
|
||||
padding: '0.5rem 1.25rem', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
borderRadius: '0.375rem',
|
||||
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.2)',
|
||||
background: isActive ? `${TEAL}18` : 'transparent',
|
||||
color: isActive ? TEAL : '#475569',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.4)'; }}}
|
||||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.2)'; }}}>
|
||||
{team}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{metrics.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Metric Health — click to filter
|
||||
{metricFilter && (
|
||||
<button onClick={() => setMetricFilter(null)}
|
||||
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||||
× clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
||||
{metrics.map(entry => (
|
||||
<MetricHealthCard
|
||||
key={entry.metric_id}
|
||||
entry={entry}
|
||||
active={metricFilter === entry.metric_id}
|
||||
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : lastUpload === null ? (
|
||||
<div style={{
|
||||
marginBottom: '1.5rem', padding: '2rem',
|
||||
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: '0.5rem',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
No compliance data — upload a report to get started
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||
<ComplianceChartsPanel />
|
||||
|
||||
{/* ── Device table ─────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Table toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
{/* Active / Resolved tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{['active', 'resolved'].map(tab => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '0.35rem 0.875rem', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
borderRadius: '0.25rem',
|
||||
border: isActive ? `1px solid ${TEAL}60` : '1px solid transparent',
|
||||
background: isActive ? `${TEAL}12` : 'transparent',
|
||||
color: isActive ? TEAL : '#475569',
|
||||
}}>
|
||||
{tab}
|
||||
{isActive && (
|
||||
<span style={{ marginLeft: '0.4rem', color: '#64748B' }}>
|
||||
({loading ? '…' : filteredDevices.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hostname search */}
|
||||
<input
|
||||
value={hostSearch}
|
||||
onChange={e => setHostSearch(e.target.value)}
|
||||
placeholder="Search hostname…"
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||
borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
|
||||
padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||
width: '220px',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}60`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||
padding: '0.5rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.62rem', color: '#334155',
|
||||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
}}>
|
||||
<span>Hostname</span>
|
||||
<span>IP Address</span>
|
||||
<span>Type</span>
|
||||
<span>Failing Metrics</span>
|
||||
<span>Seen</span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{loading ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center' }}>
|
||||
<Loader style={{ width: '28px', height: '28px', color: TEAL, margin: '0 auto', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px' }} />{error}
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||
</div>
|
||||
) : (
|
||||
filteredDevices.map(device => (
|
||||
<DeviceRow
|
||||
key={device.hostname}
|
||||
device={device}
|
||||
selected={selectedHost === device.hostname}
|
||||
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||
{selectedHost && (
|
||||
<ComplianceDetailPanel
|
||||
hostname={selectedHost}
|
||||
onClose={() => setSelectedHost(null)}
|
||||
onNoteAdded={refresh}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||||
{showUpload && (
|
||||
<ComplianceUploadModal
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUploadComplete={() => { setShowUpload(false); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({ device, selected, onClick }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||
padding: '0.625rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
background: selected ? `${TEAL}08` : 'transparent',
|
||||
borderLeft: selected ? `2px solid ${TEAL}` : '2px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Hostname */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{device.hostname}
|
||||
</div>
|
||||
|
||||
{/* IP */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B' }}>
|
||||
{device.ip_address || '—'}
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div style={{ fontSize: '0.7rem', color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{device.device_type || '—'}
|
||||
</div>
|
||||
|
||||
{/* Failing metrics */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{device.failing_metrics.map(m => (
|
||||
<MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Seen count */}
|
||||
<div>
|
||||
<SeenBadge count={device.seen_count} />
|
||||
</div>
|
||||
|
||||
{/* Notes indicator */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{device.has_notes && (
|
||||
<MessageSquare style={{ width: '13px', height: '13px', color: TEAL, opacity: 0.7 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/components/pages/ComplianceUploadModal.js
Normal file
221
frontend/src/components/pages/ComplianceUploadModal.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// phase: idle → uploading → preview → committing → done | error
|
||||
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
const [phase, setPhase] = useState('idle');
|
||||
const [previewData, setPreviewData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
setError('File must be an .xlsx spreadsheet');
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase('uploading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch(`${API_BASE}/compliance/preview`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
setPreviewData(data);
|
||||
setPhase('preview');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!previewData) return;
|
||||
setPhase('committing');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/commit`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tempFile: previewData.tempFile,
|
||||
filename: previewData.filename,
|
||||
report_date: previewData.report_date,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error || 'Commit failed');
|
||||
|
||||
setPhase('done');
|
||||
setTimeout(onUploadComplete, 1200);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const TEAL = '#14B8A6';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 60,
|
||||
background: 'rgba(10, 14, 39, 0.97)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${TEAL}40`,
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
|
||||
width: '100%', maxWidth: '480px',
|
||||
padding: '2rem',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.75rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
Upload Report
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#475569', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||
<X style={{ width: '20px', height: '20px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* IDLE — drop zone */}
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? TEAL : 'rgba(20,184,166,0.3)'}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '2.5rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? `${TEAL}08` : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
<FileSpreadsheet style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', opacity: 0.8 }} />
|
||||
<div style={{ color: '#CBD5E1', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||
Drop your xlsx file here or <span style={{ color: TEAL }}>browse</span>
|
||||
</div>
|
||||
<div style={{ color: '#475569', fontSize: '0.75rem' }}>NTS_AEO_YYYY_MM_DD.xlsx</div>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx" style={{ display: 'none' }}
|
||||
onChange={e => handleFile(e.target.files[0])} />
|
||||
{error && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* UPLOADING / COMMITTING — spinner */}
|
||||
{(phase === 'uploading' || phase === 'committing') && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<Loader style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ color: '#CBD5E1', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
{phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PREVIEW — diff summary + confirm */}
|
||||
{phase === 'preview' && previewData && (
|
||||
<>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748B', fontFamily: 'monospace', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{previewData.filename}
|
||||
{previewData.report_date && <span style={{ color: TEAL, marginLeft: '0.75rem' }}>{previewData.report_date}</span>}
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ label: 'Recurring items', count: previewData.diff.recurring_count, color: '#94A3B8', icon: '↺' },
|
||||
{ label: 'New items', count: previewData.diff.new_count, color: '#EF4444', icon: '+' },
|
||||
{ label: 'Resolved', count: previewData.diff.resolved_count, color: '#10B981', icon: '✓' },
|
||||
].map(({ label, count, color, icon }) => (
|
||||
<div key={label} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '0.75rem 1rem', marginBottom: '0.5rem',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||
border: `1px solid ${color}25`,
|
||||
}}>
|
||||
<span style={{ color: '#CBD5E1', fontSize: '0.875rem' }}>
|
||||
<span style={{ color, marginRight: '0.5rem', fontWeight: '700' }}>{icon}</span>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color, fontFamily: 'monospace', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button onClick={() => { setPhase('idle'); setPreviewData(null); }}
|
||||
style={{ flex: 1, padding: '0.625rem', background: 'transparent', border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem', color: '#64748B', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleCommit}
|
||||
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: '0.375rem', color: TEAL, cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = `${TEAL}28`}
|
||||
onMouseLeave={e => e.currentTarget.style.background = `${TEAL}18`}>
|
||||
Confirm Upload
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* DONE */}
|
||||
{phase === 'done' && (
|
||||
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||
<CheckCircle style={{ width: '44px', height: '44px', color: '#10B981', margin: '0 auto 1rem' }} />
|
||||
<div style={{ color: '#10B981', fontFamily: 'monospace', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Upload committed
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ERROR */}
|
||||
{phase === 'error' && (
|
||||
<div style={{ textAlign: 'center', padding: '1rem 0' }}>
|
||||
<AlertCircle style={{ width: '36px', height: '36px', color: '#EF4444', margin: '0 auto 0.75rem' }} />
|
||||
<div style={{ color: '#F87171', fontSize: '0.875rem', marginBottom: '1.25rem' }}>{error}</div>
|
||||
<button onClick={() => { setPhase('idle'); setError(null); }}
|
||||
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: '0.375rem', color: '#F87171', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
460
frontend/src/components/pages/ExportsPage.js
Normal file
460
frontend/src/components/pages/ExportsPage.js
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const EXC_PATTERN = /EXC-\d+/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function classifyFinding(f) {
|
||||
if (f.workflow != null) return 'fp';
|
||||
if (EXC_PATTERN.test(f.note || '')) return 'archer';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const dateStr = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function autoFit(ws, rows) {
|
||||
if (!rows[0]) return;
|
||||
ws['!cols'] = rows[0].map((_, ci) => ({
|
||||
wch: Math.min(60, Math.max(10, ...rows.map(r => String(r[ci] ?? '').length)))
|
||||
}));
|
||||
}
|
||||
|
||||
function toXLSX(rows, sheetName, filename) {
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
autoFit(ws, rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
function toMultiXLSX(sheets, filename) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
sheets.forEach(({ name, rows }) => {
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
autoFit(ws, rows);
|
||||
XLSX.utils.book_append_sheet(wb, ws, String(name || 'Unknown').slice(0, 31));
|
||||
});
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
function toCSV(rows, filename) {
|
||||
const csv = rows.map(row =>
|
||||
row.map(cell => {
|
||||
const s = String(cell ?? '');
|
||||
return (s.includes(',') || s.includes('"') || s.includes('\n'))
|
||||
? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}).join(',')
|
||||
).join('\r\n');
|
||||
triggerDownload(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), filename);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
const FINDING_HEADERS = [
|
||||
'Finding ID', 'Title', 'Severity Score', 'Severity Group',
|
||||
'Host', 'IP Address', 'DNS', 'Due Date', 'SLA Status',
|
||||
'Business Unit', 'FP# ID', 'FP# State', 'Last Found', 'CVEs', 'Notes',
|
||||
];
|
||||
|
||||
function findingRow(f) {
|
||||
return [
|
||||
f.id,
|
||||
f.title,
|
||||
f.severity != null ? Number(f.severity).toFixed(2) : '',
|
||||
f.vrrGroup ?? '',
|
||||
f.overrides?.hostName ?? f.hostName ?? '',
|
||||
f.ipAddress ?? '',
|
||||
f.overrides?.dns ?? f.dns ?? '',
|
||||
f.dueDate ?? '',
|
||||
f.slaStatus ?? '',
|
||||
f.buOwnership ?? '',
|
||||
f.workflow?.id ?? '',
|
||||
f.workflow?.state ?? '',
|
||||
f.lastFoundOn ?? '',
|
||||
(f.cves || []).join(', '),
|
||||
f.note ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API fetchers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchFindings() {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.findings || [];
|
||||
}
|
||||
|
||||
async function fetchCVEs(status) {
|
||||
const url = status ? `${API_BASE}/cves?status=${encodeURIComponent(status)}` : `${API_BASE}/cves`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CVE list returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchArcher() {
|
||||
const res = await fetch(`${API_BASE}/archer-tickets`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Archer tickets returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCompliance() {
|
||||
const res = await fetch(`${API_BASE}/cves/compliance`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Compliance data returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
function ExportCard({ color, colorRgb, icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||
border: `1px solid rgba(${colorRgb},0.2)`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<Icon style={{ width: '18px', height: '18px', color, flexShrink: 0 }} />
|
||||
<h3 style={{
|
||||
fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '600',
|
||||
color, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 12px rgba(${colorRgb},0.4)`, margin: 0,
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', margin: 0, lineHeight: 1.6 }}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabled }) {
|
||||
const isLoading = loading === exportKey;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!!loading || disabled}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.45rem 0.875rem',
|
||||
background: `rgba(${colorRgb},0.08)`,
|
||||
border: `1px solid rgba(${colorRgb},0.25)`,
|
||||
borderRadius: '0.375rem',
|
||||
color: isLoading ? '#64748B' : color,
|
||||
cursor: (!!loading || disabled) ? 'not-allowed' : 'pointer',
|
||||
opacity: (!!loading && !isLoading) ? 0.45 : 1,
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'opacity 0.15s, color 0.15s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isLoading
|
||||
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
|
||||
: <Download style={{ width: '12px', height: '12px', flexShrink: 0 }} />
|
||||
}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange, color, colorRgb }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<div
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
width: '32px', height: '18px', borderRadius: '9px',
|
||||
background: checked ? color : 'rgba(255,255,255,0.1)',
|
||||
border: `1px solid rgba(${colorRgb},0.4)`,
|
||||
position: 'relative', transition: 'background 0.2s',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: '2px',
|
||||
left: checked ? '14px' : '2px',
|
||||
width: '12px', height: '12px', borderRadius: '50%',
|
||||
background: '#E2E8F0',
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B' }}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ExportsPage() {
|
||||
const [loading, setLoading] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [cveStatus, setCveStatus] = useState('');
|
||||
const [missingOnly, setMissingOnly] = useState(false);
|
||||
|
||||
const run = useCallback(async (key, fn) => {
|
||||
setLoading(key);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
console.error('[Export]', e);
|
||||
setError(e.message || 'Export failed — check console for details');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Card 1: Ivanti Findings ----
|
||||
|
||||
const exportFullFindings = () => run('ivanti-full', async () => {
|
||||
const findings = await fetchFindings();
|
||||
toXLSX(
|
||||
[FINDING_HEADERS, ...findings.map(findingRow)],
|
||||
'All Findings',
|
||||
`findings-full-${dateStr()}.xlsx`,
|
||||
);
|
||||
});
|
||||
|
||||
const exportPending = () => run('ivanti-pending', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportOverdue = () => run('ivanti-overdue', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const today = dateStr();
|
||||
const rows = findings.filter(f => {
|
||||
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
|
||||
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
|
||||
}).map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportByBU = () => run('ivanti-bu', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const groups = {};
|
||||
findings.forEach(f => {
|
||||
const bu = f.buOwnership || 'Unknown';
|
||||
if (!groups[bu]) groups[bu] = [];
|
||||
groups[bu].push(f);
|
||||
});
|
||||
const sheets = Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, rows]) => ({ name, rows: [FINDING_HEADERS, ...rows.map(findingRow)] }));
|
||||
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [FINDING_HEADERS] });
|
||||
toMultiXLSX(sheets, `findings-by-bu-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 2: FP Workflow Summary ----
|
||||
|
||||
const exportFPSummary = () => run('fp-summary', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const fpMap = {};
|
||||
findings.forEach(f => {
|
||||
if (!f.workflow?.id) return;
|
||||
const id = f.workflow.id;
|
||||
if (!fpMap[id]) fpMap[id] = { id, state: f.workflow.state || '', count: 0, hosts: new Set(), bus: new Set(), cves: new Set() };
|
||||
fpMap[id].count++;
|
||||
const host = f.overrides?.hostName ?? f.hostName;
|
||||
if (host) fpMap[id].hosts.add(host);
|
||||
if (f.buOwnership) fpMap[id].bus.add(f.buOwnership);
|
||||
(f.cves || []).forEach(c => fpMap[id].cves.add(c));
|
||||
});
|
||||
const headers = ['FP# ID', 'State', 'Finding Count', 'Hosts', 'Business Units', 'CVEs'];
|
||||
const rows = Object.values(fpMap)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(e => [e.id, e.state, e.count, [...e.hosts].join(', '), [...e.bus].join(', '), [...e.cves].join(', ')]);
|
||||
toXLSX([headers, ...rows], 'FP Workflows', `fp-workflow-summary-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 3: CVE Database ----
|
||||
|
||||
const exportCVEs = (fmt) => run(`cves-${fmt}`, async () => {
|
||||
const data = await fetchCVEs(cveStatus);
|
||||
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Published Date', 'Description', 'Documents'];
|
||||
const rows = data.map(c => [c.cve_id, c.vendor, c.severity, c.status, c.published_date ?? '', c.description ?? '', c.document_count ?? 0]);
|
||||
if (fmt === 'csv') {
|
||||
toCSV([headers, ...rows], `cve-database-${dateStr()}.csv`);
|
||||
} else {
|
||||
toXLSX([headers, ...rows], 'CVEs', `cve-database-${dateStr()}.xlsx`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Card 4: Archer Tickets ----
|
||||
|
||||
const exportArcher = () => run('archer', async () => {
|
||||
const data = await fetchArcher();
|
||||
const headers = ['EXC Number', 'Status', 'CVE ID', 'Vendor', 'Archer URL', 'Created'];
|
||||
const rows = data.map(t => [t.exc_number, t.status, t.cve_id ?? '', t.vendor ?? '', t.archer_url ?? '', t.created_at ?? '']);
|
||||
toXLSX([headers, ...rows], 'Archer Tickets', `archer-tickets-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 5: Compliance Report ----
|
||||
|
||||
const exportCompliance = () => run('compliance', async () => {
|
||||
const data = await fetchCompliance();
|
||||
const filtered = missingOnly ? data.filter(r => r.compliance_status !== 'Complete') : data;
|
||||
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs', 'Compliance Status'];
|
||||
const rows = filtered.map(r => [r.cve_id, r.vendor, r.severity, r.status, r.total_documents, r.advisory_count, r.email_count, r.screenshot_count, r.compliance_status]);
|
||||
toXLSX([headers, ...rows], 'Compliance', `compliance-report-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
|
||||
{/* Page header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<Download style={{ width: '20px', height: '20px', color: '#8B5CF6' }} />
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139,92,246,0.4)', margin: 0 }}>
|
||||
Exports
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#EF4444', flex: 1 }}>{error}</span>
|
||||
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#EF4444', padding: 0 }}>
|
||||
<X style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))', gap: '1.5rem' }}>
|
||||
|
||||
{/* ── Card 1: Ivanti Findings ── */}
|
||||
<ExportCard
|
||||
color="#F59E0B" colorRgb="245,158,11"
|
||||
icon={BarChart2}
|
||||
title="Ivanti Host Findings"
|
||||
description="Export host findings from the local cache. Four report types: full dump, findings with no action taken, overdue SLA, and a per-business-unit multi-sheet workbook."
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||
<ExportBtn label="Full Dump" exportKey="ivanti-full" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportFullFindings} />
|
||||
<ExportBtn label="Pending Action" exportKey="ivanti-pending" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportPending} />
|
||||
<ExportBtn label="Overdue SLA" exportKey="ivanti-overdue" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportOverdue} />
|
||||
<ExportBtn label="By Business Unit" exportKey="ivanti-bu" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportByBU} />
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
"By Business Unit" creates one sheet per BU in a single workbook.
|
||||
</p>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 2: FP Workflow Summary ── */}
|
||||
<ExportCard
|
||||
color="#0EA5E9" colorRgb="14,165,233"
|
||||
icon={FileText}
|
||||
title="FP Workflow Summary"
|
||||
description="One row per unique FP# ticket ID. Shows state, how many findings belong to that ticket, which hosts are affected, and which CVEs are involved. Use this for status meetings."
|
||||
>
|
||||
<ExportBtn label="Export FP Summary (.xlsx)" exportKey="fp-summary" loading={loading} color="#0EA5E9" colorRgb="14,165,233" onClick={exportFPSummary} />
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 3: CVE Database ── */}
|
||||
<ExportCard
|
||||
color="#22C55E" colorRgb="34,197,94"
|
||||
icon={Shield}
|
||||
title="CVE Database"
|
||||
description="Export the full CVE registry. Optionally filter by status to produce a focused remediation backlog. Includes document count per entry."
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', whiteSpace: 'nowrap' }}>Status</span>
|
||||
<select
|
||||
value={cveStatus}
|
||||
onChange={e => setCveStatus(e.target.value)}
|
||||
disabled={!!loading}
|
||||
style={{
|
||||
background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)',
|
||||
borderRadius: '0.25rem', color: '#CBD5E1', padding: '0.25rem 0.5rem',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', cursor: 'pointer', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Addressed">Addressed</option>
|
||||
<option value="Resolved">Resolved</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<ExportBtn label="Export CSV" exportKey="cves-csv" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('csv')} />
|
||||
<ExportBtn label="Export .xlsx" exportKey="cves-xlsx" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('xlsx')} />
|
||||
</div>
|
||||
</div>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 4: Archer Tickets ── */}
|
||||
<ExportCard
|
||||
color="#F97316" colorRgb="249,115,22"
|
||||
icon={Tag}
|
||||
title="Archer Risk Acceptance Tickets"
|
||||
description="Export all Archer EXC exception tickets with their linked CVE IDs, vendors, statuses, and Archer URLs. Useful for risk acceptance reporting and audits."
|
||||
>
|
||||
<ExportBtn label="Export Archer Tickets (.xlsx)" exportKey="archer" loading={loading} color="#F97316" colorRgb="249,115,22" onClick={exportArcher} />
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 5: Compliance Report ── */}
|
||||
<ExportCard
|
||||
color="#EF4444" colorRgb="239,68,68"
|
||||
icon={CheckCircle}
|
||||
title="Document Compliance Report"
|
||||
description="Shows document coverage per CVE/vendor pair. A row is marked Complete when an advisory document has been uploaded; otherwise Missing Required Docs. Filter to missing-only to generate a gap list."
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Toggle
|
||||
label="Missing required docs only"
|
||||
checked={missingOnly}
|
||||
onChange={setMissingOnly}
|
||||
color="#EF4444"
|
||||
colorRgb="239,68,68"
|
||||
/>
|
||||
<ExportBtn label="Export Compliance Report (.xlsx)" exportKey="compliance" loading={loading} color="#EF4444" colorRgb="239,68,68" onClick={exportCompliance} />
|
||||
</div>
|
||||
</ExportCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
@@ -0,0 +1,207 @@
|
||||
// IvantiCountsChart.js
|
||||
// Collapsible trend panel for the Vulnerability Triage page.
|
||||
// Shows open vs closed Ivanti finding counts over time (last sync per day).
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
LineChart, Line,
|
||||
XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const AMBER = '#F59E0B';
|
||||
const SKY = '#0EA5E9';
|
||||
const GREEN = '#10B981';
|
||||
const RED = '#EF4444';
|
||||
|
||||
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom dark tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
function DarkTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const openVal = payload.find(p => p.dataKey === 'open_count')?.value;
|
||||
const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(10,17,32,0.97)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '160px',
|
||||
}}>
|
||||
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||
{label}
|
||||
</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||
<span style={{ fontWeight: '700' }}>{p.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{openVal != null && closedVal != null && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<span>total</span>
|
||||
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shorten YYYY-MM-DD to MM/DD/YY
|
||||
// ---------------------------------------------------------------------------
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
const p = d.split('-');
|
||||
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function IvantiCountsChart() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [history, setHistory] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
|
||||
if (res.ok && !cancelled) {
|
||||
const d = await res.json();
|
||||
setHistory(d.history || []);
|
||||
}
|
||||
} catch { /* silent — chart shows no-data state */ }
|
||||
finally { if (!cancelled) setLoading(false); }
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(
|
||||
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
||||
[history]
|
||||
);
|
||||
|
||||
// Compute a simple delta label for the latest vs previous point
|
||||
const deltaLabel = useMemo(() => {
|
||||
if (chartData.length < 2) return null;
|
||||
const latest = chartData[chartData.length - 1];
|
||||
const prev = chartData[chartData.length - 2];
|
||||
const delta = latest.open_count - prev.open_count;
|
||||
if (delta === 0) return { text: 'no change in open', color: '#475569' };
|
||||
if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN };
|
||||
return { text: `▲ ${delta} open since ${prev.date}`, color: RED };
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
|
||||
{/* ── Header ────────────────────────────────────────────────── */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '0 0 0.625rem 0',
|
||||
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
|
||||
marginBottom: collapsed ? 0 : '0.875rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
}}>
|
||||
Findings Trend
|
||||
</span>
|
||||
{loading && (
|
||||
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||
)}
|
||||
{!loading && deltaLabel && (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
|
||||
{deltaLabel.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{collapsed
|
||||
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||
}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(245,158,11,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem 1.25rem 0.875rem',
|
||||
}}>
|
||||
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Open vs Closed — end-of-day snapshot per sync day
|
||||
</div>
|
||||
{chartData.length > 0 && (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chartData.length < 2 ? (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '160px', color: '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
|
||||
}}>
|
||||
{chartData.length === 0
|
||||
? 'Trend data begins accumulating after the first sync — check back tomorrow'
|
||||
: 'Need at least 2 days of syncs to display a trend'}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||
<Line
|
||||
type="monotone" dataKey="open_count" name="Open"
|
||||
stroke={AMBER} strokeWidth={2}
|
||||
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone" dataKey="closed_count" name="Closed"
|
||||
stroke={SKY} strokeWidth={2}
|
||||
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
484
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
484
frontend/src/components/pages/KnowledgeBasePage.js
Normal file
@@ -0,0 +1,484 @@
|
||||
// KnowledgeBasePage.js
|
||||
// Full-page knowledge base library — browse, search, filter, and read
|
||||
// articles inline. Upload and delete require editor/admin role.
|
||||
// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components.
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
BookOpen, Search, Upload, RefreshCw, Loader,
|
||||
AlertCircle, FileText, File, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import KnowledgeBaseModal from '../KnowledgeBaseModal';
|
||||
import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const GREEN = '#10B981';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static config
|
||||
// ---------------------------------------------------------------------------
|
||||
const CATEGORY_COLORS = {
|
||||
General: '#94A3B8',
|
||||
Policy: '#0EA5E9',
|
||||
Procedure: GREEN,
|
||||
Guide: '#F59E0B',
|
||||
Reference: '#8B5CF6',
|
||||
};
|
||||
|
||||
const FILE_EXT_COLORS = {
|
||||
pdf: '#EF4444',
|
||||
md: '#10B981',
|
||||
txt: '#94A3B8',
|
||||
doc: '#0EA5E9',
|
||||
docx: '#0EA5E9',
|
||||
xls: '#10B981',
|
||||
xlsx: '#10B981',
|
||||
ppt: '#F97316',
|
||||
pptx: '#F97316',
|
||||
html: '#8B5CF6',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function extOf(filename) {
|
||||
return (filename || '').split('.').pop().toLowerCase();
|
||||
}
|
||||
|
||||
function extColor(filename) {
|
||||
return FILE_EXT_COLORS[extOf(filename)] || '#64748B';
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function fmtDate(str) {
|
||||
if (!str) return '';
|
||||
return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function catColor(cat) {
|
||||
return CATEGORY_COLORS[cat] || '#94A3B8';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArticleCard
|
||||
// ---------------------------------------------------------------------------
|
||||
function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) {
|
||||
const color = catColor(article.category);
|
||||
const fileColor = extColor(article.file_name);
|
||||
const ext = extOf(article.file_name).toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(article)}
|
||||
style={{
|
||||
background: selected
|
||||
? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)`
|
||||
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }}
|
||||
>
|
||||
{/* File type badge + delete button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700',
|
||||
color: fileColor, padding: '0.15rem 0.4rem',
|
||||
background: `${fileColor}15`, borderRadius: '0.2rem',
|
||||
border: `1px solid ${fileColor}30`,
|
||||
}}>
|
||||
{ext}
|
||||
</span>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(article); }}
|
||||
title="Delete article"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#334155', padding: '0.15rem',
|
||||
borderRadius: '0.2rem', display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
|
||||
>
|
||||
<Trash2 style={{ width: '12px', height: '12px' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700',
|
||||
color: selected ? GREEN : '#E2E8F0',
|
||||
lineHeight: 1.3,
|
||||
}}>
|
||||
{article.title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{article.description && (
|
||||
<div style={{
|
||||
fontSize: '0.7rem', color: '#475569',
|
||||
lineHeight: 1.45, display: '-webkit-box',
|
||||
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{article.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer — category + date */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||||
color, padding: '0.15rem 0.4rem',
|
||||
background: `${color}12`, borderRadius: '0.2rem',
|
||||
border: `1px solid ${color}25`,
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
}}>
|
||||
{article.category}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{article.file_size && (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||
{fmtSize(article.file_size)}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||
{fmtDate(article.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
function EmptyState({ hasFilter, onClear }) {
|
||||
return (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', padding: '4rem 2rem',
|
||||
border: '1px dashed rgba(16,185,129,0.15)', borderRadius: '0.5rem',
|
||||
color: '#334155',
|
||||
}}>
|
||||
<BookOpen style={{ width: '36px', height: '36px', marginBottom: '1rem', opacity: 0.4 }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
|
||||
{hasFilter ? 'No articles match your search' : 'No articles yet'}
|
||||
</div>
|
||||
{hasFilter ? (
|
||||
<button onClick={onClear} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: GREEN, fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
marginTop: '0.375rem',
|
||||
}}>
|
||||
Clear filters
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||
Upload a document to get started
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function KnowledgeBasePage() {
|
||||
const { canWrite } = useAuth();
|
||||
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('All');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fetch
|
||||
// -------------------------------------------------------------------------
|
||||
const fetchArticles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Failed to load articles');
|
||||
const data = await res.json();
|
||||
setArticles(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchArticles(); }, [fetchArticles]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Delete
|
||||
// -------------------------------------------------------------------------
|
||||
const handleDelete = useCallback(async (article) => {
|
||||
if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Delete failed');
|
||||
setArticles(prev => prev.filter(a => a.id !== article.id));
|
||||
if (selected?.id === article.id) setSelected(null);
|
||||
} catch (err) {
|
||||
alert(`Failed to delete: ${err.message}`);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Filtering
|
||||
// -------------------------------------------------------------------------
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return articles.filter(a => {
|
||||
const matchesCat = activeCategory === 'All' || a.category === activeCategory;
|
||||
const matchesSearch = !q ||
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
(a.description || '').toLowerCase().includes(q);
|
||||
return matchesCat && matchesSearch;
|
||||
});
|
||||
}, [articles, activeCategory, search]);
|
||||
|
||||
// Category tab counts (always from full list, not filtered by search)
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = { All: articles.length };
|
||||
CATEGORY_ORDER.forEach(cat => {
|
||||
counts[cat] = articles.filter(a => a.category === cat).length;
|
||||
});
|
||||
return counts;
|
||||
}, [articles]);
|
||||
|
||||
const activeTabs = ['All', ...CATEGORY_ORDER.filter(c => categoryCounts[c] > 0)];
|
||||
|
||||
const clearFilters = () => { setSearch(''); setActiveCategory('All'); };
|
||||
|
||||
const hasFilter = search.trim() !== '' || activeCategory !== 'All';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingBottom: '2rem' }}>
|
||||
|
||||
{/* ── Page header ─────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||
color: GREEN, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 16px ${GREEN}40`, marginBottom: '0.25rem',
|
||||
}}>
|
||||
Knowledge Base
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`}
|
||||
{articles.length > 0 && activeCategory !== 'All' && (
|
||||
<span style={{ marginLeft: '0.5rem', color: '#334155' }}>
|
||||
· {categoryCounts[activeCategory] || 0} in {activeCategory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={fetchArticles}
|
||||
title="Refresh"
|
||||
style={{
|
||||
background: 'none', border: `1px solid rgba(16,185,129,0.25)`,
|
||||
borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = GREEN; e.currentTarget.style.borderColor = `${GREEN}60`; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(16,185,129,0.25)'; }}
|
||||
>
|
||||
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
background: `${GREEN}18`, border: `1px solid ${GREEN}`,
|
||||
color: GREEN, padding: '0.5rem 1rem',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', borderRadius: '0.375rem',
|
||||
}}
|
||||
>
|
||||
<Upload style={{ width: '14px', height: '14px' }} />
|
||||
Upload Article
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + category tabs ───────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Search style={{
|
||||
position: 'absolute', left: '0.625rem', top: '50%', transform: 'translateY(-50%)',
|
||||
width: '13px', height: '13px', color: '#334155', pointerEvents: 'none',
|
||||
}} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search articles…"
|
||||
style={{
|
||||
paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem',
|
||||
paddingTop: '0.4rem', paddingBottom: '0.4rem',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
border: '1px solid rgba(16,185,129,0.2)',
|
||||
borderRadius: '0.375rem', color: '#E2E8F0',
|
||||
outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem',
|
||||
width: '220px',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${GREEN}60`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
style={{
|
||||
position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: 0,
|
||||
}}
|
||||
>
|
||||
<X style={{ width: '12px', height: '12px' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
|
||||
{activeTabs.map(cat => {
|
||||
const isActive = activeCategory === cat;
|
||||
const color = cat === 'All' ? GREEN : catColor(cat);
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
style={{
|
||||
padding: '0.35rem 0.75rem',
|
||||
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', borderRadius: '0.25rem',
|
||||
border: isActive ? `1px solid ${color}` : '1px solid transparent',
|
||||
background: isActive ? `${color}15` : 'transparent',
|
||||
color: isActive ? color : '#475569',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}}
|
||||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'transparent'; }}}
|
||||
>
|
||||
{cat}
|
||||
<span style={{ marginLeft: '0.35rem', opacity: 0.6, fontWeight: '400' }}>
|
||||
{categoryCounts[cat] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Error state ──────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.875rem 1rem',
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.5rem', color: '#F87171',
|
||||
fontFamily: 'monospace', fontSize: '0.78rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '15px', height: '15px', flexShrink: 0 }} />
|
||||
{error}
|
||||
<button
|
||||
onClick={fetchArticles}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#F87171', fontFamily: 'monospace', fontSize: '0.72rem' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Loading state ────────────────────────────────────────── */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Loader style={{ width: '28px', height: '28px', color: GREEN, animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Article grid ─────────────────────────────────────────── */}
|
||||
{!loading && !error && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: '0.875rem',
|
||||
}}>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState hasFilter={hasFilter} onClear={clearFilters} />
|
||||
) : (
|
||||
filtered.map(article => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
selected={selected?.id === article.id}
|
||||
onSelect={a => setSelected(selected?.id === a.id ? null : a)}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite()}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Inline viewer ────────────────────────────────────────── */}
|
||||
{selected && (
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
<KnowledgeBaseViewer
|
||||
article={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||||
{showUpload && (
|
||||
<KnowledgeBaseModal
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUpdate={() => { fetchArticles(); setShowUpload(false); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2342
frontend/src/components/pages/ReportingPage.js
Normal file
2342
frontend/src/components/pages/ReportingPage.js
Normal file
File diff suppressed because it is too large
Load Diff
155
ivantiAPI.py
Normal file
155
ivantiAPI.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# Ivanti API class/wrapper | Evan Compton (P2886385), updated 11/13/2025
|
||||
|
||||
### ! README | IMPORTANT INFORMATION ! ###
|
||||
# requires an "Ivanti_config.ini" file in the same directory
|
||||
# edit "Ivanti_config_template.ini", then save as "Ivanti_config.ini"
|
||||
|
||||
### ? CODE PURPOSE ? ###
|
||||
# the primary purpose of this class/wrapper is to export data as a Pandas Dataframe and/or a CSV file
|
||||
# this class primarily targets these endpoints: host, tag, hostFinding, vulnerability
|
||||
# it should work on other endpoints as well, but the 4 above are the only ones tested
|
||||
# usage examples of this class are at the end of this file
|
||||
|
||||
# library imports
|
||||
import requests, urllib3, configparser, pandas as pd
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3 import Retry
|
||||
|
||||
# fix (ignore) SSL verification...
|
||||
# Charter-specific issue; feel free to fix this if you can...
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
# Ivanti API class
|
||||
class Ivanti:
|
||||
def __init__(self, config_file='./Ivanti_config.ini'):
|
||||
# read our config file
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_file)
|
||||
|
||||
# set up environment & auth
|
||||
PLATFORM = config.get('platform', 'url') + config.get('platform', 'api_ver')
|
||||
IVANTI_API_KEY = config.get('secrets', 'api_key')
|
||||
self.CLIENT_ID = config.get('platform', 'client_id')
|
||||
self.URL_BASE = f'{PLATFORM}/client/{self.CLIENT_ID}'
|
||||
|
||||
# universal header for our requests
|
||||
self.header = {
|
||||
'x-api-key': IVANTI_API_KEY,
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
# dictionaries for filters and fields, sorted with keys by endpoint prefixes
|
||||
self.filters = {}
|
||||
self.fields = {}
|
||||
return
|
||||
|
||||
# function used for HTTP requests- thank you, Ivanti... useful code
|
||||
def request(max_retries=5, backoff_factor=0.5, status_forcelist=(419,429)):
|
||||
"""
|
||||
Create a Requests session that uses automatic retries.
|
||||
:param max_retries: Maximum number of retries to attempt
|
||||
:type max_retries: int
|
||||
:param backoff_factor: Backoff factor used to calculate time between retries.
|
||||
:type backoff_factor: float
|
||||
:param status_forcelist: A tuple containing the response status codes that should trigger a retry.
|
||||
:type status_forcelist: tuple
|
||||
:return: Requests Session
|
||||
:rtype: Requests Session Object
|
||||
"""
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=max_retries,
|
||||
read=max_retries,
|
||||
connect=max_retries,
|
||||
backoff_factor=backoff_factor,
|
||||
status_forcelist=status_forcelist,
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount('https://', adapter)
|
||||
return session
|
||||
|
||||
# retrieve all filters for an endpoint (tag, host, etc)
|
||||
def get_filters(self, endp='tag'):
|
||||
URL_FILTERS = f'{self.URL_BASE}/{endp}/filter'
|
||||
self.last_resp = self.request().get(URL_FILTERS, headers=self.header, verify=False)
|
||||
self.filters[endp] = self.last_resp.json()
|
||||
return self.filters[endp]
|
||||
|
||||
# retrieve all fields for an endpoint (tag, host, etc)
|
||||
def get_fields(self, endp='tag'):
|
||||
URL_FIELDS = f'{self.URL_BASE}/{endp}/export/template'
|
||||
self.last_resp = self.request().get(URL_FIELDS, headers=self.header, verify=False)
|
||||
self.fields[endp] = self.last_resp.json()['exportableFields']
|
||||
return self.fields[endp]
|
||||
|
||||
# this uses the "{subject}/search" endpoint instead of "{subject}/export"
|
||||
def search(self, endp='tag', save=None, pages=None, size=750):
|
||||
'''
|
||||
Uses the "/client/{client_id}/{subject}/search" endpoint to export data as JSON.
|
||||
:param endp: String for endpoint name; host, tag, group, etc. (default: "tag")
|
||||
:param save: String for filename to save, end with ".csv" (default: none)
|
||||
:param pages: Integer to limit the number of pages to pull (default: all pages)
|
||||
:param size: Integer defining how many records to pull per page (default: 750 records)
|
||||
:return: Pandas DataFrame
|
||||
'''
|
||||
# most endpoints follow the same URL structure and usage pattern
|
||||
# filters and fields dont matter for searches- only for exports!
|
||||
URL_SEARCH = f'{self.URL_BASE}/{endp}/search'
|
||||
body = {
|
||||
'projection': 'basic', # can also be set to 'detail'
|
||||
'sort': [
|
||||
{
|
||||
'field': 'id',
|
||||
'direction': 'ASC'
|
||||
}
|
||||
],
|
||||
'page': 0,
|
||||
'size': size
|
||||
}
|
||||
|
||||
# post a search, get first page
|
||||
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f'[!] ERROR: Search failed.\n- code: {resp.status_code}\n- text: {resp.text}')
|
||||
totalPages = int(resp.json()['page']['totalPages'])
|
||||
totalRecords = int(resp.json()['page']['totalElements'])
|
||||
body['page'] = int(resp.json()['page']['number']) + 1
|
||||
msg = f'[?] Search requested for "{endp}"\n[?] Total pages: {totalPages}\n[?] Total records: {totalRecords}\n[?] Batch size: {size}'
|
||||
if pages:
|
||||
msg += f'\n[?] Page limit: {pages} pages'
|
||||
print(msg)
|
||||
|
||||
# limit results?
|
||||
if pages:
|
||||
totalPages = pages
|
||||
|
||||
# loop until the last page
|
||||
subject = f'{endp[:-1]}ies' if endp.endswith('y') else f'{endp}s'
|
||||
data = []
|
||||
while body['page'] < totalPages:
|
||||
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
|
||||
body['page'] = int(resp.json()['page']['number']) + 1
|
||||
data.extend(resp.json()['_embedded'][subject])
|
||||
print(f'[?] Page progress: [{body["page"]}/{totalPages}] ({len(data)} total records retrieved)\r', end='')
|
||||
print(f'\n[+] Search completed. {len(data)} records retrieved!')
|
||||
|
||||
# make a nice dataframe, save file if wanted, return the frame
|
||||
df = pd.DataFrame(data)
|
||||
if save:
|
||||
df.to_csv(save, index=False)
|
||||
return df
|
||||
|
||||
### ? EXAMPLE USAGE ? ###
|
||||
# configure the connection and auth, create an instance object
|
||||
#API = Ivanti('./Ivanti_config.ini')
|
||||
|
||||
# the "search" function goes to the "/client/{clientID}/{subject}/search" endpoint
|
||||
#df = API.search('host', save='IvantiHostsTest_5pages.csv', pages=5)
|
||||
#df = API.search('tag', save='IvantiTagsTest_5pages.csv', pages=5)
|
||||
#df = API.search('hostFinding', save='IvantiHostFindingsTest_5pages.csv', pages=5)
|
||||
#df = API.search('vulnerability', save='IvantiVulnerabilitiesTest_5pages.csv', pages=5)
|
||||
|
||||
# you can also retrieve all possible filters and exportable fields per subject
|
||||
#filters = API.get_fields('host')
|
||||
#fields = API.get_filters('tag')
|
||||
@@ -1,7 +1,5 @@
|
||||
# Authentication Feature - Test Cases
|
||||
|
||||
**Feature Branch:** feature/login
|
||||
**Date:** 2026-01-28
|
||||
**Tester:** _______________
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user