39 Commits

Author SHA1 Message Date
f24cdb5063 feat(reporting): add Open vs Closed donut chart to Metrics panel
Backend: adds ivanti_counts_cache table, fetches Closed count (page 0,
size 1) from Ivanti after each Open sync, and exposes GET /counts endpoint.

Frontend: replaces the Metrics placeholder with an SVG donut chart showing
Open vs Closed proportions with counts and percentages. Counts are fetched
on mount and refreshed after manual sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:23:05 -06:00
3e2546323e feat(reporting): add CSV and XLSX export to findings table
Adds an Export dropdown button to the Reporting page action bar.
Exports respect current filters, sort order, and column visibility.
CSV uses pure JS (UTF-8 BOM for Excel compatibility); XLSX uses SheetJS
with auto-fitted column widths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:08:20 -06:00
b1a21e8771 docs: Add MOP for Workflow column color codes
Method of Procedure explaining FP# badge states, color meanings,
required actions, decision flowchart, and quick reference card.
Intended for training NTS-AEO team members on the Reporting page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:45:48 -06:00
bc9e223ab7 Workflow column: FP# only, urgency-based colors
- Backend: only extract FP# workflows; SYS# auto-generated tickets
  are no longer stored or shown (not actionable for triage purposes).
  Findings with no FP# ticket show blank in the workflow column.
- Frontend: recolor workflow badges by action urgency —
  Expired/Rejected = red (act now), Reworked/Actionable = amber
  (resubmit), Requested = blue (waiting on approval).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:36:02 -06:00
2d1acca990 Add Workflow column to Reporting page with FP# priority matching
- Backend: extractFinding now flattens all workflowDistribution buckets
  and prioritises FP# (False Positive) tickets over SYS# workflows.
  Falls back to workflowGeneratedNames for FP# IDs not yet in distribution.
- Frontend: Add Workflow column (sortable, filterable) with state-coloured
  badge (green=Approved, blue=Requested, amber=Reworked/Actionable,
  red=Rejected, grey=Expired/unknown).
- Bump localStorage key to v2 so the new column appears on all clients
  without needing a manual cache clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:44:53 -06:00
9893460b64 feat(reporting): add Finding ID column
ID was already stored in the cache from f.id; exposed as a sortable
column (filterable: false — too many unique values to be useful as a filter).
Existing users get it appended to the end of their saved column order
via the loadColumnOrder merge logic; new users see it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:23:50 -06:00
51b1f99b3a feat(calendar): click due-date day to navigate to filtered Reporting view
- CalendarWidget accepts onDateClick prop; due-date cells are clickable
  with pointer cursor, red hover highlight, and updated tooltip
- App.js wires onDateClick: sets calendarFilter state and navigates to
  the Reporting page
- NavDrawer navigation to Reporting clears calendarFilter so it only
  applies on calendar-initiated navigation
- ReportingPage accepts filterDate prop; initializes columnFilters with
  { dueDate: Set([filterDate]) } so the view lands pre-filtered
- Existing Clear Filters button lets the user dismiss the filter normally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:09:08 -06:00
669396f635 feat(calendar): live calendar with Ivanti due date indicators
- Replace hardcoded Feb 2024 static HTML with dynamic CalendarWidget component
- Auto-displays current month on load; prev/next chevron navigation
- Fetches /api/ivanti/findings on mount and builds a date→count map
- Days with findings due: date number rendered in red bold + red glowing dot below
- Today: sky-blue highlight + bold (combined with red if also a due date)
- Legend appears automatically when the displayed month has any due dates
- Tooltip on due-date cells shows count ("3 findings due")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:44:44 -06:00
8b3ea22fa0 Merge feature/reporting-page: full-width layout + in-panel scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:24:03 -06:00
75b8ecc61d fix(reporting): full-width layout and in-panel vertical scroll
- Reporting page breaks out of max-w-7xl container to use full viewport width
- Table body scrolls within the panel (maxHeight: calc(100vh - 420px)) so you
  no longer need to scroll the entire page to reach the horizontal scrollbar
- Column headers are sticky (position: sticky, top 0) with opaque background
  so they remain visible while scrolling vertically through findings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:23:56 -06:00
ade3cc25ad Merge feature/reporting-page: Add CVEs column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:05 -06:00
3fd6158eb3 feat(reporting): add CVEs column from vulnerabilities.vulnInfoList
- Backend extracts cves[] array from f.vulnerabilities.vulnInfoList[].cve
- Frontend shows up to 2 CVE badges (purple) with "+N more" overflow tooltip
- Filter is multi-value aware: selecting a CVE matches any finding containing it
- FilterDropdown expands multi-value arrays into individual checkbox options
- Sort by CVE count (number of associated CVEs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:01 -06:00
5bbaaf5918 Merge feature/reporting-page: BU Ownership column + column filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:20 -06:00
1f36d302ea feat(reporting): add BU Ownership column and per-column Excel-style filters
- buOwnership field extracted from assetCustomAttributes['1550_host_1'][0]
  and stored in SQLite cache; badge-styled cell (sky=STEAM, amber=ACCESS-ENG)
- All columns except Notes get a funnel filter button in the header
- FilterDropdown uses ReactDOM.createPortal + fixed positioning to escape
  overflowX:auto clipping; shows unique value checkboxes with search input,
  Select All, Clear, and a selected/total count footer
- Severity filter groups by vrrGroup label (CRITICAL/HIGH) not numeric value
- columnFilters state gates a useMemo filtered array before sorting
- Active filter count shown in panel header with amber badge; Clear Filters
  button appears in the toolbar when any filters are active
- Empty Set filter (Clear All) hides all rows, consistent with Excel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:17 -06:00
8697ba4ef3 Reporting page: add Due Date, column manager (hide/reorder), remove Discovered/Source
Backend:
- Extract dueDate from statusEmbedded.dueDate (strip time portion)
- Remove discoveredOn and source from extractFinding (not needed)

Frontend:
- Add Due Date column (color-coded: red=past due, amber=within 30d, gray=future)
- Remove Discovered and Source columns
- ColumnManager component: gear button opens popover with drag-to-reorder and
  eye toggle per column; column state persisted to localStorage
- Column order/visibility survives page refresh and syncs
- SortIcon, TableCell, NoteCell all driven by current visible column list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:47:11 -06:00
d3806e8ce3 Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side

Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
931c42faeb Merge feature/navigation: Add hamburger nav menu with 4-page navigation structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:47:47 -06:00
ea3b72db5c Add hamburger nav menu with 4-page navigation structure
- NavDrawer component: slide-in left drawer with backdrop, matches dark theme
- Nav items: Home, Reporting, Knowledge Base, Exports with color-coded icons
- Active page highlighted with colored background + indicator dot
- Placeholder pages for Reporting (amber), Knowledge Base (green), Exports (purple)
- Stats bar and three-column layout conditionally render on Home page only
- currentPage state drives all page switching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:47:03 -06:00
d63e7cc9b9 Merge feature/remove-weekly-reports: Remove weekly report functionality
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:36:55 -06:00
37e183543a Remove weekly report functionality
- Delete backend/routes/weeklyReports.js
- Delete backend/migrations/add_weekly_reports_table.js
- Delete backend/scripts/split_cve_report.py
- Delete backend/helpers/excelProcessor.js
- Delete frontend/src/components/WeeklyReportModal.js
- Remove import, state, button, and modal from App.js
- Remove route registration and require from server.js
- Drop weekly_reports table from SQLite database

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:32:39 -06:00
337ffe6f35 Merge feature/cleanup-branding: Rebrand dashboard header to STEAM Security Dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:26:19 -06:00
08c8c8a2a1 Rebrand dashboard header to STEAM Security Dashboard
- Title: "CVE INTEL" → "STEAM Security Dashboard"
- Subtitle: "Threat Intelligence & Vulnerability Command Center" → "NTS Threat Intelligence and Metric Aggregation"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:25:21 -06:00
4ed7721a71 Merge feature/workflow: Add Ivanti Workflows panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:22:43 -06:00
3fb20c147d Add Ivanti Workflows panel with API key auth and SQLite cache
- New panel below Archer tickets showing workflow count and list
- Backend proxies platform4.risksense.com workflowBatch/search via x-api-key
- SQLite cache table (ivanti_sync_state) stores latest sync result
- Auto-syncs on server startup if >24h stale, then every 24h via setInterval
- POST /api/ivanti/workflows/sync for on-demand sync with spinner feedback
- GET /api/ivanti/workflows returns cached data instantly (no live API call)
- Displays id.value, name, currentState, type, createdOn per workflow
- Shows last-synced timestamp and error messages inline
- IVANTI_SKIP_TLS flag for Charter SSL proxy environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:29:33 -06:00
f2e6069c08 docs: overhaul documentation for fork readiness
- Rewrite README from scratch: accurate stack versions, correct setup
  sequence, verified feature list, full API reference, architecture
  overview, and security model — all sourced directly from the codebase
- Remove internal/stale docs: COLOR_SCHEME_MODERNIZATION.md, plan.md,
  frontend/README.md (CRA boilerplate)
- Clean up DESIGN_SYSTEM.md: remove emoji headers and version footer
- Fix WEEKLY_REPORT_FEATURE.md: replace hardcoded absolute paths with
  relative paths
- Clean up test_cases_auth.md: remove stale branch and date references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:30:17 -07:00
c89404cf26 Add CVE list pagination to prevent endless scrolling
Shows 5 CVEs by default with 'Show 5 more' and 'Show all' controls.
Resets to 5 when filters or search change. Collapses back when fully expanded.

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 15:07:07 -07:00
b0d2f915bd added migration and feature set for archer ticekts 2026-02-18 15:02:25 -07:00
112eb8dac1 added .md to global 2026-02-17 08:56:10 -07:00
3b37646b6d Fixed issue with upload doctype 2026-02-17 08:52:26 -07:00
241ff16bb4 Fix: Allow iframe embedding from frontend origin using CSP frame-ancestors 2026-02-13 11:14:59 -07:00
0e89251bac Fix: Change X-Frame-Options to SAMEORIGIN to allow PDF iframe embedding 2026-02-13 10:50:37 -07:00
fa9f4229a6 Add PDF inline preview support to knowledge base viewer 2026-02-13 10:46:32 -07:00
eea226a9d5 Fix: Add user to useAuth destructuring for knowledge base panel 2026-02-13 10:38:33 -07:00
79a1a23002 Added knowledge base enhancements for documentation viewing and preloaded Ivanti config for next feature 2026-02-13 09:43:09 -07:00
6fda7de7a3 Merge branch 'feature/weekly-report-upload' 2026-02-13 09:27:57 -07:00
0d67a99c7e Add weekly vulnerability report upload feature
Implements a comprehensive system for uploading and processing weekly
vulnerability reports that automatically splits multiple CVE IDs in a
single cell into separate rows for easier filtering and analysis.

Backend Changes:
- Add weekly_reports table with migration
- Create Excel processor helper using Python child_process
- Implement API routes for upload, list, download, delete
- Mount routes in server.js after multer initialization
- Move split_cve_report.py to backend/scripts/

Frontend Changes:
- Add WeeklyReportModal component with phase-based UI
- Add "Weekly Report" button next to NVD Sync
- Integrate modal into App.js with state management
- Display existing reports with current report indicator
- Download buttons for original and processed files

Features:
- Upload .xlsx files (editor/admin only)
- Automatic CVE ID splitting via Python script
- Store metadata in database + files on filesystem
- Auto-archive previous reports (mark one as current)
- Download both original and processed versions
- Audit logging for all operations
- Security: file validation, auth checks, path sanitization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 16:41:39 -07:00
32 changed files with 6322 additions and 2054 deletions

View File

@@ -1,79 +0,0 @@
# CVE Dashboard - Color Scheme Modernization
## Overview
Successfully modernized the color scheme from retro 80s/neon arcade aesthetic to a professional, sophisticated tactical intelligence platform look.
## Color Palette Changes
### Before (Neon/Retro)
- **Accent**: `#00D9FF` - Bright cyan (too neon)
- **Warning**: `#FFB800` - Bright yellow/orange (too saturated)
- **Danger**: `#FF3366` - Neon pink/red
- **Success**: `#00FF88` - Bright green (too bright)
- **Background Dark**: `#0A0E27`, `#131937`, `#1E2749`
### After (Modern Professional)
- **Accent**: `#0EA5E9` - Sky Blue (professional, refined cyan)
- **Warning**: `#F59E0B` - Amber (sophisticated, warm)
- **Danger**: `#EF4444` - Modern Red (urgent but refined)
- **Success**: `#10B981` - Emerald (professional green)
- **Background Dark**: `#0F172A`, `#1E293B`, `#334155` (Tailwind Slate palette)
## Design Philosophy
### Refinement Approach
1. **Reduced Glow Intensity**: Lowered opacity and blur radius on all glows from 0.9 to 0.4-0.5
2. **Subtler Borders**: Changed from 3px bright borders to 1.5-2px refined borders
3. **Professional Gradients**: Updated background gradients to use slate tones instead of stark blues
4. **Sophisticated Shadows**: Reduced shadow intensity while maintaining depth
5. **Text Shadow Refinement**: Reduced from aggressive glows to subtle halos
### Key Changes
#### Severity Badges
- **Critical**: Neon pink → Modern red with refined glow
- **High**: Bright yellow → Amber with warm tones
- **Medium**: Bright cyan → Sky blue professional
- **Low**: Bright green → Emerald sophisticated
#### Interactive Elements
- **Buttons**: Reduced glow from 25px to 20px radius, lowered opacity
- **Input Fields**: More subtle focus states, refined borders
- **Cards**: Gentler hover effects, professional elevation
- **Stat Cards**: Refined top accent lines, subtle glows
#### Layout Components
- **Wiki Panel**: Updated to emerald accent with professional borders
- **Calendar**: Sky blue accent with refined styling
- **Tickets Panel**: Amber accent maintaining urgency without neon feel
- **CVE Cards**: Slate-based gradients with professional depth
## Technical Implementation
### Files Modified
1. **App.css**: Updated all CSS variables, component styles, and utility classes
2. **App.js**: Updated inline STYLES object and all JSX color references
### CSS Variables Updated
```css
--intel-darkest: #0F172A
--intel-dark: #1E293B
--intel-medium: #334155
--intel-accent: #0EA5E9
--intel-warning: #F59E0B
--intel-danger: #EF4444
--intel-success: #10B981
--intel-grid: rgba(14, 165, 233, 0.08)
```
### Maintained Features
✓ Pulsing button effects on hover/click
✓ Scanning line animation
✓ Card hover elevations
✓ Badge glow dots
✓ Grid background effect
✓ Three-column layout
✓ All interactive functionality
## Result
The dashboard now presents a modern, professional tactical intelligence platform aesthetic while preserving all the visual interest, depth, and functionality that made the original design engaging. The color scheme feels premium and sophisticated rather than arcade-like, suitable for enterprise security operations.

View File

@@ -1,6 +1,6 @@
# CVE Intelligence Dashboard - Design System Reference # CVE Intelligence Dashboard - Design System Reference
## 🎨 Color Palette ## Color Palette
### Primary Colors ### Primary Colors
```css ```css
@@ -33,7 +33,7 @@
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` | | **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` | | **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
## 📐 Layout Structure ## Layout Structure
### Three-Column Grid Layout ### Three-Column Grid Layout
``` ```
@@ -60,7 +60,7 @@
- **Desktop (lg+)**: 3-column layout (3-6-3 grid) - **Desktop (lg+)**: 3-column layout (3-6-3 grid)
- **Tablet/Mobile**: Stacked single column - **Tablet/Mobile**: Stacked single column
## 🎯 Component Specifications ## Component Specifications
### Stat Cards ### Stat Cards
```css ```css
@@ -117,7 +117,7 @@ Letter Spacing: 0.5px
Glow Dot: 8px circle with pulse animation Glow Dot: 8px circle with pulse animation
``` ```
## Interactions & Animations ## Interactions & Animations
### Hover Effects ### Hover Effects
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow - **Cards**: `translateY(-2px)`, enhanced border, subtle glow
@@ -151,7 +151,7 @@ Fast: all 0.2s ease
Ripple: width/height 0.5s Ripple: width/height 0.5s
``` ```
## 🔤 Typography ## Typography
### Font Families ### Font Families
```css ```css
@@ -178,7 +178,7 @@ Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0
Badge Text: 0 0 8px rgba([color], 0.5) Badge Text: 0 0 8px rgba([color], 0.5)
``` ```
## 🎨 Visual Effects ## Visual Effects
### Shadows ### Shadows
```css ```css
@@ -223,7 +223,7 @@ linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
Size: 20px × 20px Size: 20px × 20px
``` ```
## 🧩 Specific Component Patterns ## Specific Component Patterns
### Wiki/Knowledge Base Entry ### Wiki/Knowledge Base Entry
```css ```css
@@ -261,7 +261,7 @@ Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
Vendor Cards: Nested with reduced opacity borders Vendor Cards: Nested with reduced opacity borders
``` ```
## 📱 Accessibility ## Accessibility
### Contrast Ratios ### Contrast Ratios
- Primary text on dark: 18.5:1 (AAA) - Primary text on dark: 18.5:1 (AAA)
@@ -278,7 +278,7 @@ Vendor Cards: Nested with reduced opacity borders
- Line height: 1.5 for body text - Line height: 1.5 for body text
- Letter spacing: Generous for uppercase labels - Letter spacing: Generous for uppercase labels
## 🎯 Design Principles ## Design Principles
1. **Professional Sophistication**: Modern enterprise feel, not arcade 1. **Professional Sophistication**: Modern enterprise feel, not arcade
2. **Tactical Intelligence**: Purpose-driven, information-dense 2. **Tactical Intelligence**: Purpose-driven, information-dense
@@ -288,7 +288,3 @@ Vendor Cards: Nested with reduced opacity borders
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity 6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
7. **Generous Spacing**: Breathing room prevents overwhelming density 7. **Generous Spacing**: Breathing room prevents overwhelming density
---
**Last Updated**: February 10, 2026
**Version**: 2.0 (Modern Professional Redesign)

View 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>

1841
README.md

File diff suppressed because it is too large Load Diff

211
WEEKLY_REPORT_FEATURE.md Normal file
View File

@@ -0,0 +1,211 @@
# Weekly Vulnerability Report Upload Feature
## Overview
A new feature has been added to the CVE Dashboard that allows users to upload their weekly vulnerability reports in Excel format (.xlsx) and automatically process them to split multiple CVE IDs into separate rows for easier filtering and analysis.
## What Was Implemented
### Backend Changes
1. **Database Migration** (`backend/migrations/add_weekly_reports_table.js`)
- Created `weekly_reports` table to store report metadata
- Tracks upload date, file paths, row counts, and which report is current
- Indexed for fast queries
2. **Excel Processor** (`backend/helpers/excelProcessor.js`)
- Executes Python script via Node.js child_process
- Parses row counts from Python output
- Handles errors, timeouts (30 seconds), and validation
3. **API Routes** (`backend/routes/weeklyReports.js`)
- `POST /api/weekly-reports/upload` - Upload and process Excel file
- `GET /api/weekly-reports` - List all reports
- `GET /api/weekly-reports/:id/download/:type` - Download original or processed file
- `DELETE /api/weekly-reports/:id` - Delete report (admin only)
4. **Python Script** (`backend/scripts/split_cve_report.py`)
- Moved from ~/Documents to backend/scripts
- Splits comma-separated CVE IDs into separate rows
- Duplicates device/IP data for each CVE
### Frontend Changes
1. **Weekly Report Modal** (`frontend/src/components/WeeklyReportModal.js`)
- Phase-based UI: idle → uploading → processing → success
- File upload with .xlsx validation
- Display existing reports with current report indicator (★)
- Download buttons for both original and processed files
2. **App.js Integration**
- Added "Weekly Report" button next to NVD Sync button
- State management for modal visibility
- Modal rendering
## How to Use
### Starting the Application
1. **Backend:**
```bash
cd backend
node server.js
```
2. **Frontend:**
```bash
cd frontend
npm start
```
### Using the Feature
1. **Access the Feature**
- Login as an editor or admin user
- Look for the "Weekly Report" button in the top header (next to "NVD Sync")
2. **Upload a Report**
- Click the "Weekly Report" button
- Click "Choose File" and select your .xlsx file
- Click "Upload & Process"
- Wait for processing to complete (usually 5-10 seconds)
3. **Download Processed Report**
- After upload succeeds, you'll see row counts (e.g., "45 → 67 rows")
- Click "Download Processed" to get the split version
- The current week's report is marked with a ★ star icon
4. **Access Previous Reports**
- All previous reports are listed below the upload section
- Click the download icons to get original or processed versions
- Reports are labeled as "This week's report", "Last week's report", or by date
### What the Processing Does
**Before Processing:**
| HOSTNAME | IP | CVE ID |
|----------|------------|---------------------------|
| server01 | 10.0.0.1 | CVE-2024-1234, CVE-2024-5678 |
**After Processing:**
| HOSTNAME | IP | CVE ID |
|----------|------------|---------------------------|
| server01 | 10.0.0.1 | CVE-2024-1234 |
| server01 | 10.0.0.1 | CVE-2024-5678 |
Each CVE now has its own row, making it easy to:
- Sort by CVE ID
- Filter for specific CVEs
- Research CVEs one by one per device
## File Locations
### New Files Created
```
backend/
scripts/
split_cve_report.py # Python script for CVE splitting
requirements.txt # Python dependencies
routes/
weeklyReports.js # API endpoints
helpers/
excelProcessor.js # Python integration
migrations/
add_weekly_reports_table.js # Database migration
uploads/
weekly_reports/ # Uploaded and processed files
frontend/
src/
components/
WeeklyReportModal.js # Upload modal UI
```
### Modified Files
```
backend/
server.js # Added route mounting
frontend/
src/
App.js # Added button and modal
```
## Security & Permissions
- **Upload**: Requires editor or admin role
- **Download**: Any authenticated user
- **Delete**: Admin only
- **File Validation**: Only .xlsx files accepted, 10MB limit
- **Audit Logging**: All uploads, downloads, and deletions are logged
## Troubleshooting
### Backend Issues
**Python not found:**
```bash
# Install Python 3
sudo apt-get install python3
```
**Missing dependencies:**
```bash
# Install pandas and openpyxl
pip3 install pandas openpyxl
```
**Port already in use:**
```bash
# Find and kill process using port 3001
lsof -i :3001
kill -9 <PID>
```
### Frontend Issues
**Button not visible:**
- Make sure you're logged in as editor or admin
- Viewer role cannot upload reports
**Upload fails:**
- Check file is .xlsx format (not .xls or .csv)
- Ensure file has "Vulnerabilities" sheet with "CVE ID" column
- Check file size is under 10MB
**Processing timeout:**
- Large files (10,000+ rows) may timeout
- Try reducing file size or increase timeout in `excelProcessor.js`
## Testing Checklist
- [x] Backend starts without errors
- [x] Frontend compiles successfully
- [x] Database migration completed
- [x] Python dependencies installed
- [ ] Upload .xlsx file (manual test in browser)
- [ ] Verify processed file has split CVEs (manual test)
- [ ] Download original and processed files (manual test)
- [ ] Verify current report marked with star (manual test)
- [ ] Test as viewer - button should be hidden (manual test)
## Future Enhancements
Possible improvements:
- Progress bar during Python processing
- Email notifications when processing completes
- Scheduled automatic uploads
- Report comparison (diff between weeks)
- Export to other formats (CSV, JSON)
- Bulk delete old reports
- Report validation before upload
## Support
For issues or questions:
1. Check the troubleshooting section above
2. Review audit logs for error details
3. Check browser console for frontend errors
4. Review backend server logs for API errors

838
architecture.excalidraw Normal file
View File

@@ -0,0 +1,838 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.com",
"elements": [
{
"id": "title-text",
"type": "text",
"x": 400,
"y": 30,
"width": 400,
"height": 45,
"angle": 0,
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 1,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "CVE Dashboard Architecture",
"fontSize": 36,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "top",
"baseline": 32,
"containerId": null,
"originalText": "CVE Dashboard Architecture"
},
{
"id": "users-box",
"type": "ellipse",
"x": 500,
"y": 120,
"width": 200,
"height": 80,
"angle": 0,
"strokeColor": "#1971c2",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 2,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "users-text"
},
{
"id": "arrow-users-frontend",
"type": "arrow"
}
],
"updated": 1,
"link": null,
"locked": false
},
{
"id": "users-text",
"type": "text",
"x": 505,
"y": 145,
"width": 190,
"height": 30,
"angle": 0,
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 3,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "Users\n(Admin/Editor/Viewer)",
"fontSize": 16,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"baseline": 23,
"containerId": "users-box",
"originalText": "Users\n(Admin/Editor/Viewer)"
},
{
"id": "frontend-box",
"type": "rectangle",
"x": 450,
"y": 250,
"width": 300,
"height": 120,
"angle": 0,
"strokeColor": "#1971c2",
"backgroundColor": "#a5d8ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 4,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "frontend-text"
},
{
"id": "arrow-users-frontend",
"type": "arrow"
},
{
"id": "arrow-frontend-backend",
"type": "arrow"
}
],
"updated": 1,
"link": null,
"locked": false
},
{
"id": "frontend-text",
"type": "text",
"x": 455,
"y": 255,
"width": 290,
"height": 110,
"angle": 0,
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 5,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "middle",
"baseline": 103,
"containerId": "frontend-box",
"originalText": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views"
},
{
"id": "backend-box",
"type": "rectangle",
"x": 400,
"y": 420,
"width": 400,
"height": 180,
"angle": 0,
"strokeColor": "#7048e8",
"backgroundColor": "#d0bfff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 6,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "backend-text"
},
{
"id": "arrow-frontend-backend",
"type": "arrow"
},
{
"id": "arrow-backend-db",
"type": "arrow"
},
{
"id": "arrow-backend-storage",
"type": "arrow"
},
{
"id": "arrow-backend-nvd",
"type": "arrow"
}
],
"updated": 1,
"link": null,
"locked": false
},
{
"id": "backend-text",
"type": "text",
"x": 405,
"y": 425,
"width": 390,
"height": 170,
"angle": 0,
"strokeColor": "#7048e8",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 7,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "middle",
"baseline": 163,
"containerId": "backend-box",
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports"
},
{
"id": "db-box",
"type": "rectangle",
"x": 200,
"y": 680,
"width": 280,
"height": 140,
"angle": 0,
"strokeColor": "#2f9e44",
"backgroundColor": "#b2f2bb",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 8,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "db-text"
},
{
"id": "arrow-backend-db",
"type": "arrow"
}
],
"updated": 1,
"link": null,
"locked": false
},
{
"id": "db-text",
"type": "text",
"x": 205,
"y": 685,
"width": 270,
"height": 130,
"angle": 0,
"strokeColor": "#2f9e44",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 9,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "middle",
"baseline": 123,
"containerId": "db-box",
"originalText": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log"
},
{
"id": "storage-box",
"type": "rectangle",
"x": 550,
"y": 680,
"width": 280,
"height": 140,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "#ffec99",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 10,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "storage-text"
},
{
"id": "arrow-backend-storage",
"type": "arrow"
}
],
"updated": 1,
"link": null,
"locked": false
},
{
"id": "storage-text",
"type": "text",
"x": 555,
"y": 685,
"width": 270,
"height": 130,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 11,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "middle",
"baseline": 123,
"containerId": "storage-box",
"originalText": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames"
},
{
"id": "nvd-box",
"type": "rectangle",
"x": 900,
"y": 420,
"width": 220,
"height": 100,
"angle": 0,
"strokeColor": "#e03131",
"backgroundColor": "#ffc9c9",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 12,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "nvd-text"
},
{
"id": "arrow-backend-nvd",
"type": "arrow"
}
],
"updated": 1,
"link": null,
"locked": false
},
{
"id": "nvd-text",
"type": "text",
"x": 905,
"y": 425,
"width": 210,
"height": 90,
"angle": 0,
"strokeColor": "#e03131",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 13,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"baseline": 83,
"containerId": "nvd-box",
"originalText": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup"
},
{
"id": "arrow-users-frontend",
"type": "arrow",
"x": 600,
"y": 200,
"width": 0,
"height": 50,
"angle": 0,
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "round",
"seed": 14,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"points": [
[0, 0],
[0, 50]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "users-box",
"focus": 0,
"gap": 1
},
"endBinding": {
"elementId": "frontend-box",
"focus": 0,
"gap": 1
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false,
"roundness": null
},
{
"id": "arrow-frontend-backend",
"type": "arrow",
"x": 600,
"y": 370,
"width": 0,
"height": 50,
"angle": 0,
"strokeColor": "#7048e8",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "round",
"seed": 15,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"points": [
[0, 0],
[0, 50]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "frontend-box",
"focus": 0,
"gap": 1
},
"endBinding": {
"elementId": "backend-box",
"focus": 0,
"gap": 1
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false,
"roundness": null
},
{
"id": "arrow-backend-db",
"type": "arrow",
"x": 500,
"y": 600,
"width": -140,
"height": 80,
"angle": 0,
"strokeColor": "#2f9e44",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "round",
"seed": 16,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"points": [
[0, 0],
[-140, 0],
[-140, 80]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "backend-box",
"focus": 0,
"gap": 1
},
"endBinding": {
"elementId": "db-box",
"focus": 0,
"gap": 1
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true,
"roundness": null
},
{
"id": "arrow-backend-storage",
"type": "arrow",
"x": 700,
"y": 600,
"width": 0,
"height": 80,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "round",
"seed": 17,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"points": [
[0, 0],
[0, 80]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "backend-box",
"focus": 0.5,
"gap": 1
},
"endBinding": {
"elementId": "storage-box",
"focus": 0.5,
"gap": 1
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false,
"roundness": null
},
{
"id": "arrow-backend-nvd",
"type": "arrow",
"x": 800,
"y": 480,
"width": 100,
"height": 0,
"angle": 0,
"strokeColor": "#e03131",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "round",
"seed": 18,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"points": [
[0, 0],
[100, 0]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "backend-box",
"focus": 0,
"gap": 1
},
"endBinding": {
"elementId": "nvd-box",
"focus": 0,
"gap": 1
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false,
"roundness": null
},
{
"id": "label-http",
"type": "text",
"x": 610,
"y": 390,
"width": 100,
"height": 20,
"angle": 0,
"strokeColor": "#7048e8",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 19,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "HTTP/REST API",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "top",
"baseline": 17,
"containerId": null,
"originalText": "HTTP/REST API"
},
{
"id": "label-https",
"type": "text",
"x": 820,
"y": 460,
"width": 60,
"height": 20,
"angle": 0,
"strokeColor": "#e03131",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 20,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "HTTPS",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "top",
"baseline": 17,
"containerId": null,
"originalText": "HTTPS"
},
{
"id": "auth-note",
"type": "text",
"x": 100,
"y": 250,
"width": 280,
"height": 80,
"angle": 0,
"strokeColor": "#495057",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 21,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "top",
"baseline": 73,
"containerId": null,
"originalText": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)"
},
{
"id": "features-note",
"type": "text",
"x": 900,
"y": 580,
"width": 280,
"height": 120,
"angle": 0,
"strokeColor": "#495057",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"strokeSharpness": "sharp",
"seed": 22,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"boundElements": null,
"updated": 1,
"link": null,
"locked": false,
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "top",
"baseline": 113,
"containerId": null,
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging"
}
],
"appState": {
"gridSize": null,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s) # 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 # Request one at https://nvd.nist.gov/developers/request-an-api-key
NVD_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

View File

@@ -0,0 +1,50 @@
// Migration: Add archer_tickets table
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting Archer tickets migration...');
db.serialize(() => {
// Create archer_tickets table
db.run(`
CREATE TABLE IF NOT EXISTS archer_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exc_number TEXT NOT NULL UNIQUE,
archer_url TEXT,
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
cve_id TEXT NOT NULL,
vendor TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
)
`, (err) => {
if (err) console.error('Error creating table:', err);
else console.log('✓ archer_tickets table created');
});
// Create indexes
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor)', (err) => {
if (err) console.error('Error creating CVE index:', err);
else console.log('✓ CVE index created');
});
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status)', (err) => {
if (err) console.error('Error creating status index:', err);
else console.log('✓ Status index created');
});
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number)', (err) => {
if (err) console.error('Error creating EXC number index:', err);
else console.log('✓ EXC number index created');
});
console.log('✓ Indexes created');
});
db.close(() => {
console.log('Migration complete!');
});

View File

@@ -0,0 +1,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!');
});

View 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!');
});

View 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();
});
});

View File

@@ -0,0 +1,223 @@
// routes/archerTickets.js
const express = require('express');
const { requireAuth, requireRole } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
// Validation helpers
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
function isValidCveId(cveId) {
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
}
function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
}
function createArcherTicketsRouter(db) {
const router = express.Router();
// Get all Archer tickets (with optional filters)
router.get('/', requireAuth(db), (req, res) => {
const { cve_id, vendor, status } = req.query;
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
const params = [];
if (cve_id) {
query += ' AND cve_id = ?';
params.push(cve_id);
}
if (vendor) {
query += ' AND vendor = ?';
params.push(vendor);
}
if (status) {
query += ' AND status = ?';
params.push(status);
}
query += ' ORDER BY created_at DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching Archer tickets:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows);
});
});
// Create Archer ticket
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
// Validation
if (!exc_number || typeof exc_number !== 'string' || exc_number.trim().length === 0) {
return res.status(400).json({ error: 'EXC number is required.' });
}
if (!/^EXC-\d+$/.test(exc_number.trim())) {
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
}
if (!cve_id || !isValidCveId(cve_id)) {
return res.status(400).json({ error: 'Valid CVE ID is required.' });
}
if (!vendor || !isValidVendor(vendor)) {
return res.status(400).json({ error: 'Valid vendor is required.' });
}
if (archer_url && (typeof archer_url !== 'string' || archer_url.length > 500)) {
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
}
if (status && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
}
const validatedStatus = status || 'Draft';
db.run(
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
VALUES (?, ?, ?, ?, ?)`,
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
function(err) {
if (err) {
console.error('Error creating Archer ticket:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
}
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
action: 'CREATE_ARCHER_TICKET',
targetType: 'archer_ticket',
targetId: this.lastID,
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
ipAddress: req.ip
});
res.status(201).json({
id: this.lastID,
message: 'Archer ticket created successfully'
});
}
);
});
// Update Archer ticket
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { id } = req.params;
const { exc_number, archer_url, status } = req.body;
// Validation
if (exc_number !== undefined) {
if (typeof exc_number !== 'string' || exc_number.trim().length === 0) {
return res.status(400).json({ error: 'EXC number cannot be empty.' });
}
if (!/^EXC-\d+$/.test(exc_number.trim())) {
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
}
}
if (archer_url !== undefined && archer_url !== null && (typeof archer_url !== 'string' || archer_url.length > 500)) {
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
}
if (status !== undefined && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
}
// Get existing ticket
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) {
return res.status(404).json({ error: 'Archer ticket not found.' });
}
const updates = [];
const params = [];
if (exc_number !== undefined) {
updates.push('exc_number = ?');
params.push(exc_number.trim());
}
if (archer_url !== undefined) {
updates.push('archer_url = ?');
params.push(archer_url || null);
}
if (status !== undefined) {
updates.push('status = ?');
params.push(status);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(id);
db.run(
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
params,
function(err) {
if (err) {
console.error(err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
}
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
action: 'UPDATE_ARCHER_TICKET',
targetType: 'archer_ticket',
targetId: id,
details: { before: existing, changes: req.body },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
}
);
});
});
// Delete Archer ticket
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { id } = req.params;
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) {
return res.status(404).json({ error: 'Archer ticket not found.' });
}
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
action: 'DELETE_ARCHER_TICKET',
targetType: 'archer_ticket',
targetId: id,
details: { deleted: ticket },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket deleted successfully' });
});
});
});
return router;
}
module.exports = createArcherTicketsRouter;

View File

@@ -0,0 +1,464 @@
// 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 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); });
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 INDEX IF NOT EXISTS idx_finding_notes_finding_id
ON ivanti_finding_notes(finding_id)
`, (err) => {
if (err) reject(err);
else resolve();
});
});
});
}
// ---------------------------------------------------------------------------
// Extract only the fields we need from a raw finding object
// ---------------------------------------------------------------------------
function extractFinding(f) {
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
const rawDueDate = f.statusEmbedded?.dueDate || '';
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
// CVE list: vulnerabilities.vulnInfoList[].cve
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
// system workflows and not actionable for our purposes.
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
...(wfDist.approvedWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
// Priority: actionable > requested > reworked > rejected > expired > approved
const fpEntry = fpBuckets[0] || null;
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
const generatedNames = f.workflowGeneratedNames || [];
const fpFromNames = !fpEntry
? generatedNames.find(n => n.startsWith('FP#')) || null
: null;
const workflow = fpEntry ? {
id: fpEntry.generatedId || '',
state: fpEntry.state || '',
type: 'FP',
} : fpFromNames ? {
id: fpFromNames,
state: '',
type: 'FP',
} : null;
return {
id: String(f.id),
title: f.title || '',
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
vrrGroup: f.vrrGroup || f.severityGroup || '',
hostName: f.host?.hostName || '',
ipAddress: f.host?.ipAddress || '',
dns: f.dns || f.host?.fqdn || '',
status: f.status || '',
slaStatus: f.slaStatus || '',
dueDate,
lastFoundOn: f.lastFoundOn || '',
buOwnership,
cves,
workflow
};
}
// ---------------------------------------------------------------------------
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
// ---------------------------------------------------------------------------
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
try {
const body = {
filters: CLOSED_COUNT_FILTERS,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page: 0,
size: 1
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
const data = JSON.parse(result.body);
// RiskSense returns total in page.totalElements or page.total
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
await dbRun(db,
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
[openCount, closedCount]
);
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
} catch (err) {
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
// Still update open count so it stays in sync; leave closed_count as-is
await dbRun(db,
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
[openCount]
).catch(() => {});
}
}
// ---------------------------------------------------------------------------
// 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);
} 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,
});
}
);
});
}
async function readStateWithNotes(db) {
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
state.findings = state.findings.map((f) => ({ ...f, note: notes[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' });
}
});
// 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;

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
pandas>=2.0.0
openpyxl>=3.0.0

View File

@@ -18,6 +18,10 @@ const createUsersRouter = require('./routes/users');
const createAuditLogRouter = require('./routes/auditLog'); const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog'); const logAudit = require('./helpers/auditLog');
const createNvdLookupRouter = require('./routes/nvdLookup'); const createNvdLookupRouter = require('./routes/nvdLookup');
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
const createArcherTicketsRouter = require('./routes/archerTickets');
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -32,7 +36,7 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
// Allowed file extensions for document uploads (documents only, no executables) // Allowed file extensions for document uploads (documents only, no executables)
const ALLOWED_EXTENSIONS = new Set([ const ALLOWED_EXTENSIONS = new Set([
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
'.txt', '.csv', '.log', '.msg', '.eml', '.txt', '.md', '.csv', '.log', '.msg', '.eml',
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp', '.odt', '.ods', '.odp',
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml', '.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
@@ -94,7 +98,7 @@ app.use((req, res, next) => {
// Security headers // Security headers
app.use((req, res, next) => { app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
@@ -106,7 +110,11 @@ app.use(cors({
origin: CORS_ORIGINS, origin: CORS_ORIGINS,
credentials: true credentials: true
})); }));
app.use(express.json({ limit: '1mb' })); // Only parse JSON for requests with application/json content type
app.use(express.json({
limit: '1mb',
type: 'application/json'
}));
app.use(cookieParser()); app.use(cookieParser());
app.use('/uploads', express.static('uploads', { app.use('/uploads', express.static('uploads', {
dotfiles: 'deny', dotfiles: 'deny',
@@ -167,6 +175,18 @@ const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
}); });
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
// Ivanti / RiskSense workflow routes (all authenticated users)
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
// Ivanti / RiskSense host findings routes (all authenticated users)
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
// ========== CVE ENDPOINTS ========== // ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users) // Get all CVEs with optional filters (authenticated users)

View 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*

View File

@@ -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)

View File

@@ -10,8 +10,10 @@
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@@ -647,3 +647,179 @@ h3.text-intel-accent {
inset 0 2px 4px rgba(0, 0, 0, 0.25), inset 0 2px 4px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(14, 165, 233, 0.1); 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;
}

View File

@@ -1,11 +1,18 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown } from 'lucide-react'; import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm'; import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu'; import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement'; import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog'; import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal'; import NvdSyncModal from './components/NvdSyncModal';
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
import NavDrawer from './components/NavDrawer';
import CalendarWidget from './components/CalendarWidget';
import ReportingPage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import './App.css'; import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -155,7 +162,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() { export default function App() {
const { isAuthenticated, loading: authLoading, canWrite, isAdmin } = useAuth(); const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
@@ -169,10 +176,16 @@ export default function App() {
const [cveDocuments, setCveDocuments] = useState({}); const [cveDocuments, setCveDocuments] = useState({});
const [quickCheckCVE, setQuickCheckCVE] = useState(''); const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null); const [quickCheckResult, setQuickCheckResult] = useState(null);
const [currentPage, setCurrentPage] = useState('home');
const [navOpen, setNavOpen] = useState(false);
const [calendarFilter, setCalendarFilter] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false); const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false); const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false); const [showNvdSync, setShowNvdSync] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
const [newCVE, setNewCVE] = useState({ const [newCVE, setNewCVE] = useState({
cve_id: '', cve_id: '',
vendor: '', vendor: '',
@@ -193,6 +206,7 @@ export default function App() {
const [editNvdError, setEditNvdError] = useState(null); const [editNvdError, setEditNvdError] = useState(null);
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false); const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
const [expandedCVEs, setExpandedCVEs] = useState({}); const [expandedCVEs, setExpandedCVEs] = useState({});
const [visibleCount, setVisibleCount] = useState(5);
const [jiraTickets, setJiraTickets] = useState([]); const [jiraTickets, setJiraTickets] = useState([]);
const [showAddTicket, setShowAddTicket] = useState(false); const [showAddTicket, setShowAddTicket] = useState(false);
const [showEditTicket, setShowEditTicket] = useState(false); const [showEditTicket, setShowEditTicket] = useState(false);
@@ -203,6 +217,25 @@ export default function App() {
// For adding ticket from within a CVE card // For adding ticket from within a CVE card
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor } const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
// Archer tickets state
const [archerTickets, setArcherTickets] = useState([]);
const [showAddArcherTicket, setShowAddArcherTicket] = useState(false);
const [showEditArcherTicket, setShowEditArcherTicket] = useState(false);
const [editingArcherTicket, setEditingArcherTicket] = useState(null);
const [archerTicketForm, setArcherTicketForm] = useState({
exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: ''
});
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
// Ivanti workflows state
const [ivantiTotal, setIvantiTotal] = useState(null);
const [ivantiWorkflows, setIvantiWorkflows] = useState([]);
const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null);
const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null);
const [ivantiSyncError, setIvantiSyncError] = useState(null);
const [ivantiLoading, setIvantiLoading] = useState(false);
const [ivantiSyncing, setIvantiSyncing] = useState(false);
const toggleCVEExpand = (cveId) => { const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] })); setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
}; };
@@ -276,6 +309,19 @@ export default function App() {
} }
}; };
const fetchKnowledgeBaseArticles = async () => {
try {
const response = await fetch(`${API_BASE}/knowledge-base`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
const data = await response.json();
setKnowledgeBaseArticles(data);
} catch (err) {
console.error('Error fetching knowledge base articles:', err);
}
};
const fetchJiraTickets = async () => { const fetchJiraTickets = async () => {
try { try {
const response = await fetch(`${API_BASE}/jira-tickets`, { const response = await fetch(`${API_BASE}/jira-tickets`, {
@@ -289,6 +335,56 @@ export default function App() {
} }
}; };
const fetchArcherTickets = async () => {
try {
const response = await fetch(`${API_BASE}/archer-tickets`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch Archer tickets');
const data = await response.json();
setArcherTickets(data);
} catch (err) {
console.error('Error fetching Archer tickets:', err);
}
};
const applyIvantiState = (data) => {
setIvantiTotal(data.total ?? 0);
setIvantiWorkflows(data.workflows || []);
setIvantiSyncedAt(data.synced_at || null);
setIvantiSyncStatus(data.sync_status || null);
setIvantiSyncError(data.error_message || null);
};
const fetchIvantiWorkflows = async () => {
setIvantiLoading(true);
try {
const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
const data = await response.json();
if (response.ok) applyIvantiState(data);
} catch (err) {
console.error('Error loading Ivanti workflows:', err);
} finally {
setIvantiLoading(false);
}
};
const syncIvantiWorkflows = async () => {
setIvantiSyncing(true);
try {
const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, {
method: 'POST',
credentials: 'include'
});
const data = await response.json();
if (response.ok) applyIvantiState(data);
} catch (err) {
console.error('Error syncing Ivanti workflows:', err);
} finally {
setIvantiSyncing(false);
}
};
const fetchDocuments = async (cveId, vendor) => { const fetchDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`; const key = `${cveId}-${vendor}`;
if (cveDocuments[key]) return; if (cveDocuments[key]) return;
@@ -344,6 +440,45 @@ export default function App() {
alert(`Exporting ${selectedDocuments.length} documents for report attachment`); alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
}; };
const handleViewKBArticle = async (articleId) => {
try {
const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch article');
const article = await response.json();
setSelectedKBArticle(article);
} catch (err) {
console.error('Error fetching knowledge base article:', err);
setError('Failed to load article');
}
};
const handleDownloadKBArticle = 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 knowledge base article:', err);
setError('Failed to download document');
}
};
const handleAddCVE = async (e) => { const handleAddCVE = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -686,12 +821,100 @@ export default function App() {
setShowAddTicket(true); setShowAddTicket(true);
}; };
// ========== ARCHER TICKET HANDLERS ==========
const handleAddArcherTicket = async (e) => {
e.preventDefault();
try {
const response = await fetch(`${API_BASE}/archer-tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(archerTicketForm)
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create Archer ticket');
}
alert('Archer ticket added successfully!');
setShowAddArcherTicket(false);
setAddArcherTicketContext(null);
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
fetchArcherTickets();
} catch (err) {
alert(`Error: ${err.message}`);
}
};
const handleEditArcherTicket = (ticket) => {
setEditingArcherTicket(ticket);
setArcherTicketForm({
exc_number: ticket.exc_number,
archer_url: ticket.archer_url || '',
status: ticket.status,
cve_id: ticket.cve_id,
vendor: ticket.vendor
});
setShowEditArcherTicket(true);
};
const handleUpdateArcherTicket = async (e) => {
e.preventDefault();
try {
const response = await fetch(`${API_BASE}/archer-tickets/${editingArcherTicket.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
exc_number: archerTicketForm.exc_number,
archer_url: archerTicketForm.archer_url,
status: archerTicketForm.status
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update Archer ticket');
}
alert('Archer ticket updated!');
setShowEditArcherTicket(false);
setEditingArcherTicket(null);
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
fetchArcherTickets();
} catch (err) {
alert(`Error: ${err.message}`);
}
};
const handleDeleteArcherTicket = async (ticket) => {
if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return;
try {
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to delete Archer ticket');
alert('Archer ticket deleted');
fetchArcherTickets();
} catch (err) {
alert(`Error: ${err.message}`);
}
};
const openAddArcherTicketForCVE = (cve_id, vendor) => {
setAddArcherTicketContext({ cve_id, vendor });
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor });
setShowAddArcherTicket(true);
};
// Fetch CVEs from API when authenticated // Fetch CVEs from API when authenticated
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
fetchCVEs(); fetchCVEs();
fetchVendors(); fetchVendors();
fetchJiraTickets(); fetchJiraTickets();
fetchArcherTickets();
fetchIvantiWorkflows();
fetchKnowledgeBaseArticles();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]); }, [isAuthenticated]);
@@ -700,6 +923,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
fetchCVEs(); fetchCVEs();
setVisibleCount(5);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]); }, [searchQuery, selectedVendor, selectedSeverity]);
@@ -734,18 +958,39 @@ export default function App() {
return ( return (
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in"> <div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
<NavDrawer
isOpen={navOpen}
onClose={() => setNavOpen(false)}
currentPage={currentPage}
onNavigate={(page) => {
// Clear calendar filter when navigating directly via the nav drawer
if (page === 'reporting') setCalendarFilter(null);
setCurrentPage(page);
}}
/>
{/* Scanning line effect */} {/* Scanning line effect */}
<div className="scan-line"></div> <div className="scan-line"></div>
<div className="max-w-7xl mx-auto relative z-10"> <div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-start mb-6"> <div className="flex justify-between items-start mb-6">
<div className="flex-1"> <div className="flex items-center gap-4 flex-1">
<button
onClick={() => setNavOpen(true)}
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
title="Navigation"
>
<Menu className="w-5 h-5" />
</button>
<div>
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight"> <h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
CVE INTEL STEAM Security Dashboard
</h1> </h1>
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p> <p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{canWrite() && ( {canWrite() && (
@@ -770,8 +1015,8 @@ export default function App() {
</div> </div>
</div> </div>
{/* Stats Bar - Modern refined styling */} {/* Stats Bar - only shown on Home page */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div style={STYLES.statCard}> <div style={STYLES.statCard}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div> <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div> <div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
@@ -792,8 +1037,13 @@ export default function App() {
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div> <div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div> <div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
</div> </div>
</div>}
</div> </div>
</div>
{/* Page content */}
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
{/* User Management Modal */} {/* User Management Modal */}
{showUserManagement && ( {showUserManagement && (
@@ -810,6 +1060,14 @@ export default function App() {
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} /> <NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)} )}
{/* Knowledge Base Modal */}
{showKnowledgeBase && (
<KnowledgeBaseModal
onClose={() => setShowKnowledgeBase(false)}
onUpdate={fetchKnowledgeBaseArticles}
/>
)}
{/* Add CVE Modal */} {/* Add CVE Modal */}
{showAddCVE && ( {showAddCVE && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
@@ -1255,52 +1513,235 @@ export default function App() {
</div> </div>
)} )}
{/* Three Column Layout */} {/* Add Archer Ticket Modal */}
<div className="grid grid-cols-12 gap-6"> {showAddArcherTicket && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-purple-400 font-mono">Add Archer Risk Ticket</h2>
<button onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
<XCircle className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleAddArcherTicket} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
<input
type="text"
required
placeholder="EXC-5754"
value={archerTicketForm.exc_number}
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
<input
type="url"
placeholder="https://archer.example.com/..."
value={archerTicketForm.archer_url}
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
<input
type="text"
required
placeholder="CVE-2024-1234"
value={archerTicketForm.cve_id}
onChange={(e) => setArcherTicketForm({...archerTicketForm, cve_id: e.target.value.toUpperCase()})}
className="intel-input w-full"
readOnly={!!addArcherTicketContext}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
<input
type="text"
required
placeholder="Vendor name"
value={archerTicketForm.vendor}
onChange={(e) => setArcherTicketForm({...archerTicketForm, vendor: e.target.value})}
className="intel-input w-full"
readOnly={!!addArcherTicketContext}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
<select
value={archerTicketForm.status}
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
className="intel-input w-full"
>
<option value="Draft">Draft</option>
<option value="Open">Open</option>
<option value="Under Review">Under Review</option>
<option value="Accepted">Accepted</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">
Create Ticket
</button>
<button type="button" onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Edit Archer Ticket Modal */}
{showEditArcherTicket && editingArcherTicket && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-purple-400 font-mono">Edit Archer Risk Ticket</h2>
<button onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
{editingArcherTicket.cve_id} / {editingArcherTicket.vendor}
</div>
<form onSubmit={handleUpdateArcherTicket} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
<input
type="text"
required
value={archerTicketForm.exc_number}
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
<input
type="url"
value={archerTicketForm.archer_url}
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
<select
value={archerTicketForm.status}
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
className="intel-input w-full"
>
<option value="Draft">Draft</option>
<option value="Open">Open</option>
<option value="Under Review">Under Review</option>
<option value="Accepted">Accepted</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">
Save Changes
</button>
<button type="button" onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Three Column Layout - Home page only */}
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
{/* LEFT PANEL - Wiki/Knowledge Base */} {/* LEFT PANEL - Wiki/Knowledge Base */}
<div className="col-span-12 lg:col-span-3 space-y-4"> <div className="col-span-12 lg:col-span-3 space-y-4">
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg"> <div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '1rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}> <div className="flex items-center justify-between mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
Knowledge Base Knowledge Base
</h2> </h2>
{(user?.role === 'admin' || user?.role === 'editor') && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="intel-button intel-button-small"
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem' }}
title="Manage Knowledge Base"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
{/* Wiki/Blog Style Entries */} {/* Knowledge Base Entries */}
<div className="space-y-3"> <div className="space-y-3">
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success"> {knowledgeBaseArticles.length === 0 ? (
<h3 className="text-white font-semibold text-sm mb-1 font-mono">CVE Response Procedures</h3> <div className="text-center py-8" style={{ color: '#64748B' }}>
<p className="text-gray-400 text-xs mb-2">Standard operating procedures for vulnerability response and escalation...</p> <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-08</span> <p className="text-sm">No documents yet</p>
{(user?.role === 'admin' || user?.role === 'editor') && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="intel-button intel-button-small mt-3"
>
Add First Document
</button>
)}
</div> </div>
) : (
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success"> knowledgeBaseArticles.slice(0, 5).map((article) => (
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Vendor Contact Matrix</h3> <div
<p className="text-gray-400 text-xs mb-2">Emergency contacts and escalation paths for security vendors...</p> key={article.id}
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-05</span> onClick={() => handleViewKBArticle(article.id)}
style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }}
className="hover:border-intel-success"
>
<h3 className="text-white font-semibold text-sm mb-1 font-mono">{article.title}</h3>
{article.description && (
<p className="text-gray-400 text-xs mb-2 line-clamp-2">{article.description}</p>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-intel-success font-mono">
{new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
{article.category && article.category !== 'General' && (
<span className="text-xs px-2 py-0.5 rounded" style={{ background: 'rgba(16, 185, 129, 0.2)', color: '#10B981' }}>
{article.category}
</span>
)}
</div> </div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Severity Classification Guide</h3>
<p className="text-gray-400 text-xs mb-2">Guidelines for assessing and classifying vulnerability severity levels...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-28</span>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Patching Policy</h3>
<p className="text-gray-400 text-xs mb-2">Enterprise patch management timelines and approval workflow...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-15</span>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Documentation Standards</h3>
<p className="text-gray-400 text-xs mb-2">Required documentation for vulnerability tracking and audit compliance...</p>
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-10</span>
</div> </div>
))
)}
{knowledgeBaseArticles.length > 5 && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="text-xs text-center w-full py-2"
style={{ color: '#10B981' }}
>
View all {knowledgeBaseArticles.length} documents
</button>
)}
</div> </div>
</div> </div>
</div> </div>
{/* CENTER PANEL - Main Content */} {/* CENTER PANEL - Main Content */}
<div className="col-span-12 lg:col-span-6 space-y-4"> <div className="col-span-12 lg:col-span-6 space-y-4">
{/* Knowledge Base Viewer */}
{selectedKBArticle ? (
<KnowledgeBaseViewer
article={selectedKBArticle}
onClose={() => setSelectedKBArticle(null)}
/>
) : (
<>
{/* Quick Check */} {/* Quick Check */}
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg"> <div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
<div className="scan-line"></div> <div className="scan-line"></div>
@@ -1455,7 +1896,7 @@ export default function App() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => { {Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
const isCVEExpanded = expandedCVEs[cveId]; const isCVEExpanded = expandedCVEs[cveId];
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 }; const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
const highestSeverity = vendorEntries.reduce((highest, entry) => { const highestSeverity = vendorEntries.reduce((highest, entry) => {
@@ -1727,6 +2168,40 @@ export default function App() {
</div> </div>
); );
})} })}
{/* Show more / pagination footer */}
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
<div className="flex items-center justify-between pt-2">
<span className="text-gray-500 font-mono text-xs">
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
</span>
<div className="flex gap-2">
<button
onClick={() => setVisibleCount(v => v + 5)}
className="intel-button intel-button-primary text-xs px-3 py-1"
>
Show 5 more
</button>
<button
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Show all
</button>
</div>
</div>
)}
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
<div className="flex justify-end pt-2">
<button
onClick={() => setVisibleCount(5)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Collapse
</button>
</div>
)}
</div> </div>
)} )}
@@ -1737,6 +2212,8 @@ export default function App() {
<p className="text-gray-300">Try adjusting your search criteria or filters</p> <p className="text-gray-300">Try adjusting your search criteria or filters</p>
</div> </div>
)} )}
</>
)}
</div> </div>
{/* End Center Panel */} {/* End Center Panel */}
@@ -1748,63 +2225,12 @@ export default function App() {
Calendar Calendar
</h2> </h2>
{/* Simple Calendar Grid */} <CalendarWidget
<div className="mb-2"> onDateClick={(dateStr) => {
<div className="text-center mb-3"> setCalendarFilter(dateStr);
<span className="text-white font-semibold font-mono">February 2024</span> setCurrentPage('reporting');
</div> }}
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2"> />
<div className="text-gray-400 font-mono">Su</div>
<div className="text-gray-400 font-mono">Mo</div>
<div className="text-gray-400 font-mono">Tu</div>
<div className="text-gray-400 font-mono">We</div>
<div className="text-gray-400 font-mono">Th</div>
<div className="text-gray-400 font-mono">Fr</div>
<div className="text-gray-400 font-mono">Sa</div>
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{/* Week 1 */}
<div className="text-gray-600 font-mono text-xs p-1">28</div>
<div className="text-gray-600 font-mono text-xs p-1">29</div>
<div className="text-gray-600 font-mono text-xs p-1">30</div>
<div className="text-gray-600 font-mono text-xs p-1">31</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
{/* Week 2 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
{/* Week 3 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
{/* Week 4 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
{/* Week 5 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
<div className="text-gray-600 font-mono text-xs p-1">1</div>
<div className="text-gray-600 font-mono text-xs p-1">2</div>
</div>
</div>
</div> </div>
{/* Open Vendor Tickets */} {/* Open Vendor Tickets */}
@@ -1871,10 +2297,165 @@ export default function App() {
)} )}
</div> </div>
</div> </div>
{/* Archer Risk Acceptance Tickets */}
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
<div className="flex justify-between items-center mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
<Shield className="w-5 h-5" />
Archer Risk Tickets
</h2>
{canWrite() && (
<button
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
{archerTickets.filter(t => t.status !== 'Accepted').length}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
<div className="flex items-start justify-between gap-2 mb-1">
<a
href={ticket.archer_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
>
{ticket.exc_number}
</a>
{canWrite() && (
<div className="flex gap-1">
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
<div className="text-xs text-gray-400">{ticket.vendor}</div>
<div className="mt-2">
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
{ticket.status}
</span>
</div>
</div>
))}
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
</div>
)}
</div>
</div>
{/* Ivanti Workflows */}
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
<div className="flex justify-between items-center mb-1">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
<Activity className="w-5 h-5" />
Ivanti Workflows
</h2>
<button
onClick={syncIvantiWorkflows}
disabled={ivantiSyncing || ivantiLoading}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
title="Sync now"
>
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
{ivantiSyncing ? 'Syncing…' : 'Sync'}
</button>
</div>
{/* Last synced line */}
<div className="text-xs text-gray-500 font-mono mb-4">
{ivantiSyncedAt
? `Synced ${new Date(ivantiSyncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
: 'Never synced'}
</div>
{ivantiLoading ? (
<div className="text-center py-8">
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
<p className="text-xs text-gray-400 font-mono">Loading...</p>
</div>
) : ivantiSyncStatus === 'error' ? (
<>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
{ivantiTotal ?? '—'}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
</div>
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
<p className="text-xs text-red-400 font-mono">{ivantiSyncError}</p>
</div>
</>
) : (
<>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
{ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{ivantiWorkflows.slice(0, 10).map((wf, idx) => (
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(13, 148, 136, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="font-mono text-xs font-semibold text-teal-300">
{wf.id?.value || wf.uuid?.slice(0, 8)}
</span>
{wf.currentState && (
<span style={{ fontSize: '0.65rem', padding: '0.2rem 0.4rem', borderRadius: '0.25rem', background: 'rgba(13, 148, 136, 0.2)', border: '1px solid #0D9488', color: '#0D9488', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
{wf.currentState}
</span>
)}
</div>
<div className="text-xs text-white truncate mb-1">{wf.name}</div>
<div className="flex items-center justify-between gap-2">
{wf.type && (
<span className="text-xs text-gray-400 font-mono">{wf.type.replace(/_/g, ' ')}</span>
)}
{wf.createdOn && (
<span className="text-xs text-gray-500">{wf.createdOn}</span>
)}
</div>
</div>
))}
{ivantiSyncStatus !== 'never' && ivantiTotal === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No workflows found</p>
</div>
)}
{ivantiSyncStatus === 'never' && (
<div className="text-center py-6">
<p className="text-xs text-gray-500 font-mono">Click Sync to load workflow data</p>
</div>
)}
</div>
</>
)}
</div>
</div> </div>
{/* End Right Panel */} {/* End Right Panel */}
</div> </div>}
{/* End Three Column Layout */} {/* End Three Column Layout */}
</div> </div>
</div> </div>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
{ id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
];
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0, 0, 0, 0.65)',
backdropFilter: 'blur(3px)',
zIndex: 50
}}
/>
{/* Drawer */}
<div style={{
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
zIndex: 51,
display: 'flex', flexDirection: 'column',
padding: '1.5rem'
}}>
{/* Drawer header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
<div>
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
STEAM
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
Security Dashboard
</div>
</div>
<button
onClick={onClose}
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
>
<X style={{ width: '20px', height: '20px' }} />
</button>
</div>
{/* Nav items */}
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
const active = currentPage === id;
return (
<button
key={id}
onClick={() => { onNavigate(id); onClose(); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.875rem',
padding: '0.75rem 0.875rem',
borderRadius: '0.5rem',
border: active ? `1px solid ${color}50` : '1px solid transparent',
background: active ? `${color}18` : 'transparent',
cursor: 'pointer', textAlign: 'left', width: '100%',
transition: 'background 0.15s, border-color 0.15s'
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
{/* Icon box */}
<div style={{
width: '36px', height: '36px', flexShrink: 0,
borderRadius: '0.375rem',
background: `${color}18`,
border: `1px solid ${color}40`,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Icon style={{ width: '17px', height: '17px', color }} />
</div>
{/* Label + description */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
color: active ? color : '#CBD5E1',
textTransform: 'uppercase', letterSpacing: '0.06em'
}}>
{label}
</div>
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
{description}
</div>
</div>
{/* Active indicator dot */}
{active && (
<div style={{
width: '6px', height: '6px', borderRadius: '50%',
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
}} />
)}
</button>
);
})}
</nav>
{/* Footer */}
<div style={{
marginTop: 'auto', paddingTop: '1rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
textAlign: 'center'
}}>
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
NTS Threat Intelligence
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Download } from 'lucide-react';
export default function ExportsPage() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
background: 'rgba(139, 92, 246, 0.1)',
border: '1px solid rgba(139, 92, 246, 0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Download style={{ width: '36px', height: '36px', color: '#8B5CF6' }} />
</div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
Exports
</h2>
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
Under construction coming soon
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { BookOpen } from 'lucide-react';
export default function KnowledgeBasePage() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} />
</div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
Knowledge Base
</h2>
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
Under construction coming soon
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

155
ivantiAPI.py Normal file
View File

@@ -0,0 +1,155 @@
# Ivanti API class/wrapper | Evan Compton (P2886385), updated 11/13/2025
### ! README | IMPORTANT INFORMATION ! ###
# requires an "Ivanti_config.ini" file in the same directory
# edit "Ivanti_config_template.ini", then save as "Ivanti_config.ini"
### ? CODE PURPOSE ? ###
# the primary purpose of this class/wrapper is to export data as a Pandas Dataframe and/or a CSV file
# this class primarily targets these endpoints: host, tag, hostFinding, vulnerability
# it should work on other endpoints as well, but the 4 above are the only ones tested
# usage examples of this class are at the end of this file
# library imports
import requests, urllib3, configparser, pandas as pd
from requests.adapters import HTTPAdapter
from urllib3 import Retry
# fix (ignore) SSL verification...
# Charter-specific issue; feel free to fix this if you can...
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)
# Ivanti API class
class Ivanti:
def __init__(self, config_file='./Ivanti_config.ini'):
# read our config file
config = configparser.ConfigParser()
config.read(config_file)
# set up environment & auth
PLATFORM = config.get('platform', 'url') + config.get('platform', 'api_ver')
IVANTI_API_KEY = config.get('secrets', 'api_key')
self.CLIENT_ID = config.get('platform', 'client_id')
self.URL_BASE = f'{PLATFORM}/client/{self.CLIENT_ID}'
# universal header for our requests
self.header = {
'x-api-key': IVANTI_API_KEY,
'content-type': 'application/json'
}
# dictionaries for filters and fields, sorted with keys by endpoint prefixes
self.filters = {}
self.fields = {}
return
# function used for HTTP requests- thank you, Ivanti... useful code
def request(max_retries=5, backoff_factor=0.5, status_forcelist=(419,429)):
"""
Create a Requests session that uses automatic retries.
:param max_retries: Maximum number of retries to attempt
:type max_retries: int
:param backoff_factor: Backoff factor used to calculate time between retries.
:type backoff_factor: float
:param status_forcelist: A tuple containing the response status codes that should trigger a retry.
:type status_forcelist: tuple
:return: Requests Session
:rtype: Requests Session Object
"""
session = requests.Session()
retry = Retry(
total=max_retries,
read=max_retries,
connect=max_retries,
backoff_factor=backoff_factor,
status_forcelist=status_forcelist,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('https://', adapter)
return session
# retrieve all filters for an endpoint (tag, host, etc)
def get_filters(self, endp='tag'):
URL_FILTERS = f'{self.URL_BASE}/{endp}/filter'
self.last_resp = self.request().get(URL_FILTERS, headers=self.header, verify=False)
self.filters[endp] = self.last_resp.json()
return self.filters[endp]
# retrieve all fields for an endpoint (tag, host, etc)
def get_fields(self, endp='tag'):
URL_FIELDS = f'{self.URL_BASE}/{endp}/export/template'
self.last_resp = self.request().get(URL_FIELDS, headers=self.header, verify=False)
self.fields[endp] = self.last_resp.json()['exportableFields']
return self.fields[endp]
# this uses the "{subject}/search" endpoint instead of "{subject}/export"
def search(self, endp='tag', save=None, pages=None, size=750):
'''
Uses the "/client/{client_id}/{subject}/search" endpoint to export data as JSON.
:param endp: String for endpoint name; host, tag, group, etc. (default: "tag")
:param save: String for filename to save, end with ".csv" (default: none)
:param pages: Integer to limit the number of pages to pull (default: all pages)
:param size: Integer defining how many records to pull per page (default: 750 records)
:return: Pandas DataFrame
'''
# most endpoints follow the same URL structure and usage pattern
# filters and fields dont matter for searches- only for exports!
URL_SEARCH = f'{self.URL_BASE}/{endp}/search'
body = {
'projection': 'basic', # can also be set to 'detail'
'sort': [
{
'field': 'id',
'direction': 'ASC'
}
],
'page': 0,
'size': size
}
# post a search, get first page
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
if resp.status_code != 200:
raise Exception(f'[!] ERROR: Search failed.\n- code: {resp.status_code}\n- text: {resp.text}')
totalPages = int(resp.json()['page']['totalPages'])
totalRecords = int(resp.json()['page']['totalElements'])
body['page'] = int(resp.json()['page']['number']) + 1
msg = f'[?] Search requested for "{endp}"\n[?] Total pages: {totalPages}\n[?] Total records: {totalRecords}\n[?] Batch size: {size}'
if pages:
msg += f'\n[?] Page limit: {pages} pages'
print(msg)
# limit results?
if pages:
totalPages = pages
# loop until the last page
subject = f'{endp[:-1]}ies' if endp.endswith('y') else f'{endp}s'
data = []
while body['page'] < totalPages:
resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False)
body['page'] = int(resp.json()['page']['number']) + 1
data.extend(resp.json()['_embedded'][subject])
print(f'[?] Page progress: [{body["page"]}/{totalPages}] ({len(data)} total records retrieved)\r', end='')
print(f'\n[+] Search completed. {len(data)} records retrieved!')
# make a nice dataframe, save file if wanted, return the frame
df = pd.DataFrame(data)
if save:
df.to_csv(save, index=False)
return df
### ? EXAMPLE USAGE ? ###
# configure the connection and auth, create an instance object
#API = Ivanti('./Ivanti_config.ini')
# the "search" function goes to the "/client/{clientID}/{subject}/search" endpoint
#df = API.search('host', save='IvantiHostsTest_5pages.csv', pages=5)
#df = API.search('tag', save='IvantiTagsTest_5pages.csv', pages=5)
#df = API.search('hostFinding', save='IvantiHostFindingsTest_5pages.csv', pages=5)
#df = API.search('vulnerability', save='IvantiVulnerabilitiesTest_5pages.csv', pages=5)
# you can also retrieve all possible filters and exportable fields per subject
#filters = API.get_fields('host')
#fields = API.get_filters('tag')

297
plan.md
View File

@@ -1,297 +0,0 @@
# NVD Lookup + Retroactive Sync — Implementation Plan
## Overview
Two capabilities on `feature/nvd-lookup` branch:
1. **Auto-fill on Add CVE** (DONE, stashed) — onBlur NVD lookup fills description/severity/date in the Add CVE modal
2. **Sync with NVD** (TO DO) — bulk tool for editors/admins to retroactively update existing CVE entries from NVD, with per-CVE choice to keep or replace description
## Current State
### Git State
- **Branch:** `feature/nvd-lookup` (branched from master post-audit-merge)
- **Stash:** `stash@{0}` contains the auto-fill implementation (4 files)
- **Master** now has audit logging (merged from feature/audit on 2026-01-30)
- Offsite repo is up to date through the feature/audit merge to master
### What's in the Stash
The stash contains working NVD auto-fill code that needs to be popped and conflict-resolved before continuing:
**`backend/routes/nvdLookup.js` (NEW file)**
- Factory function: `createNvdLookupRouter(db, requireAuth)`
- `GET /lookup/:cveId` endpoint
- Validates CVE ID format (regex: `CVE-YYYY-NNNNN`)
- Calls `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...`
- 10-second timeout via `AbortSignal.timeout(10000)`
- Optional `apiKey` header from `NVD_API_KEY` env var
- CVSS severity cascade: v3.1 → v3.0 → v2.0
- Maps NVD uppercase severity to app format (CRITICAL→Critical, etc.)
- Returns: `{ description, severity, published_date }`
**`backend/server.js` (MODIFIED)**
- Adds `const createNvdLookupRouter = require('./routes/nvdLookup');`
- Adds `app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));`
**`frontend/src/App.js` (MODIFIED)**
- New state: `nvdLoading`, `nvdError`, `nvdAutoFilled`
- New function: `lookupNVD(cveId)` — calls backend, auto-fills form fields
- CVE ID input: `onBlur` triggers lookup, `onChange` resets NVD feedback
- Spinner (Loader icon) in CVE ID field while loading
- Green "Auto-filled from NVD" with CheckCircle on success
- Amber warning with AlertCircle on errors (non-blocking)
- Description only fills if currently empty; severity + published_date always update
- NVD state resets on modal close (X, Cancel) and form submit
**`backend/.env.example` (MODIFIED)**
- Adds `NVD_API_KEY=` with comment about rate limits
### Stash Conflict Resolution
Popping the stash will conflict in `server.js` because master now has audit imports that didn't exist when the stash was created. Resolution:
The conflict is in the imports section. Keep ALL existing audit lines from master:
```js
const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog');
```
AND add the NVD line:
```js
const createNvdLookupRouter = require('./routes/nvdLookup');
```
Similarly, keep the audit route mount and add the NVD mount after it:
```js
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
```
Then `git add backend/server.js` to mark resolved and `git stash drop`.
---
## Step 1: Resolve Stash + Rebase onto Master
```bash
git checkout feature/nvd-lookup
git rebase master # Get audit changes into the branch
git stash pop # Apply NVD changes (will conflict in server.js)
# Resolve conflict in server.js as described above
git add backend/server.js
git stash drop
```
Verify: `backend/routes/nvdLookup.js` exists, `server.js` has both audit AND NVD imports/mounts.
---
## Step 2: Backend — New Endpoints in `server.js`
### 2A: `GET /api/cves/distinct-ids`
Place BEFORE `GET /api/cves/check/:cveId` (to avoid route param conflict):
```js
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows.map(r => r.cve_id));
});
});
```
### 2B: `POST /api/cves/nvd-sync`
Place after the existing `PATCH /api/cves/:cveId/status`:
```js
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { updates } = req.body;
if (!Array.isArray(updates) || updates.length === 0) {
return res.status(400).json({ error: 'No updates provided' });
}
let updated = 0;
const errors = [];
let completed = 0;
db.serialize(() => {
updates.forEach((entry) => {
const fields = [];
const values = [];
if (entry.description !== null && entry.description !== undefined) {
fields.push('description = ?');
values.push(entry.description);
}
if (entry.severity !== null && entry.severity !== undefined) {
fields.push('severity = ?');
values.push(entry.severity);
}
if (entry.published_date !== null && entry.published_date !== undefined) {
fields.push('published_date = ?');
values.push(entry.published_date);
}
if (fields.length === 0) {
completed++;
if (completed === updates.length) sendResponse();
return;
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(entry.cve_id);
db.run(
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
values,
function(err) {
if (err) {
errors.push({ cve_id: entry.cve_id, error: err.message });
} else {
updated += this.changes;
}
completed++;
if (completed === updates.length) sendResponse();
}
);
});
});
function sendResponse() {
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'cve_nvd_sync',
entityType: 'cve',
entityId: null,
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
ipAddress: req.ip
});
const result = { message: 'NVD sync completed', updated };
if (errors.length > 0) result.errors = errors;
res.json(result);
}
});
```
**How "keep existing description" works:** If the user chooses to keep the existing description, the frontend sends `description: null` for that CVE. The backend skips null fields, so the description is not overwritten. Severity and published_date are always sent (auto-update).
---
## Step 3: Frontend — New `NvdSyncModal.js` Component
**File:** `frontend/src/components/NvdSyncModal.js`
### Props
```jsx
<NvdSyncModal onClose={fn} onSyncComplete={fn} />
```
### Phase Machine
| Phase | What's shown |
|-------|-------------|
| `idle` | CVE count + "Fetch NVD Data" button |
| `fetching` | Progress bar, current CVE being fetched, cancel button |
| `review` | Comparison table with per-CVE description choice |
| `applying` | Spinner |
| `done` | Summary (X updated, Y errors) + Close button |
### Fetching Logic
- Iterate CVE IDs sequentially
- Call `GET /api/nvd/lookup/:cveId` for each
- 7-second delay between requests (safe for 5 req/30s without API key)
- On 429: wait 35 seconds, retry once
- On 404: mark as "Not found in NVD" (gray, skipped)
- On timeout/error: mark with warning (skipped)
- Support cancellation via AbortController
### Comparison Table Columns
| Column | Content |
|--------|---------|
| CVE ID | The identifier |
| Status | Icon: check=found, warning=error, dash=no changes |
| Severity | `[Current] → [NVD]` with color badges, or "No change" |
| Published Date | `Current → NVD` or "No change" |
| Description | Truncated preview with expand toggle. Current (red bg) vs NVD (green bg) when different |
| Choice | Radio: "Keep existing" (default) / "Use NVD" — only shown when descriptions differ |
### Bulk Controls
Above the table:
- Summary: `Found: N | Up to date: N | Changes: N | Not in NVD: N | Errors: N`
- Bulk toggle: "Keep All Existing" / "Use All NVD Descriptions"
Below the table:
- "Apply N Changes" button (count updates dynamically)
- "Cancel" button
### Apply Logic
Build updates array:
- For each CVE with NVD data (no error):
- Always include `severity` and `published_date` if different from current
- Include `description` only if user chose "Use NVD" — otherwise send `null`
- Skip CVEs where nothing changed
- POST to `/api/cves/nvd-sync`
- On success: call `onSyncComplete()` to refresh CVE list, then show done phase
---
## Step 4: Frontend — App.js Integration
Minimal changes following `AuditLog`/`UserManagement` pattern:
1. **Import:** Add `NvdSyncModal` and `RefreshCw` icon
2. **State:** Add `const [showNvdSync, setShowNvdSync] = useState(false);`
3. **Header button** (next to "Add CVE/Vendor", visible to editors/admins):
```jsx
{canWrite() && (
<button onClick={() => setShowNvdSync(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md">
<RefreshCw className="w-5 h-5" />
Sync with NVD
</button>
)}
```
4. **Modal render** (alongside other modals):
```jsx
{showNvdSync && (
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)}
```
---
## Step 5: AuditLog Badge
**File:** `frontend/src/components/AuditLog.js`
Add to the `ACTION_BADGES` object:
```js
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
```
---
## Step 6: .env.example (already in stash)
```
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
# Request one at https://nvd.nist.gov/developers/request-an-api-key
NVD_API_KEY=
```
---
## File Summary
| File | Action | Lines Changed (est.) |
|------|--------|---------------------|
| `backend/server.js` | Modify | +40 (NVD mount + 2 new endpoints) |
| `backend/routes/nvdLookup.js` | From stash | 0 (already complete) |
| `backend/.env.example` | From stash | +3 |
| `frontend/src/components/NvdSyncModal.js` | New | ~350-400 |
| `frontend/src/App.js` | Modify | +10 (import, state, button, modal) |
| `frontend/src/components/AuditLog.js` | Modify | +1 (badge entry) |
---
## Verification Checklist
1. Pop stash, resolve conflict, verify `nvdLookup.js` and server.js are correct
2. Test NVD lookup via curl: `curl -b cookie.txt http://localhost:3001/api/nvd/lookup/CVE-2024-3094`
3. Test distinct-ids: `curl -b cookie.txt http://localhost:3001/api/cves/distinct-ids`
4. Open Add CVE modal, type CVE ID, tab out → verify auto-fill works
5. Click "Sync with NVD" button → modal opens with CVE count
6. Click "Fetch NVD Data" → progress bar, rate-limited fetching
7. Review comparison table → verify diffs shown correctly
8. Toggle description choices, click "Apply" → verify database updated
9. Confirm main CVE list refreshes with new data
10. Check audit log for `cve_nvd_sync` entry

View File

@@ -1,7 +1,5 @@
# Authentication Feature - Test Cases # Authentication Feature - Test Cases
**Feature Branch:** feature/login
**Date:** 2026-01-28
**Tester:** _______________ **Tester:** _______________
--- ---