chore: reorganize docs, document migrations, gitignore operational files for v1.0.0 release

This commit is contained in:
Jordan Ramos
2026-05-01 20:53:39 +00:00
parent c8b3626ac5
commit 034d3963b9
39 changed files with 792 additions and 917 deletions

Binary file not shown.

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

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>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,290 @@
# CVE Intelligence Dashboard - Design System Reference
## Color Palette
### Primary Colors
```css
--intel-darkest: #0F172A /* Slate 900 - Deepest background */
--intel-dark: #1E293B /* Slate 800 - Card backgrounds */
--intel-medium: #334155 /* Slate 700 - Elevated elements */
```
### Accent & Status Colors
```css
--intel-accent: #0EA5E9 /* Sky Blue - Primary accent, links, interactive elements */
--intel-warning: #F59E0B /* Amber - Warnings, high severity, open tickets */
--intel-danger: #EF4444 /* Red - Critical severity, destructive actions */
--intel-success: #10B981 /* Emerald - Success states, low severity, confirmations */
```
### Text Colors
```css
--text-primary: #F8FAFC /* Slate 50 - Primary text */
--text-secondary: #E2E8F0 /* Slate 200 - Secondary text */
--text-tertiary: #CBD5E1 /* Slate 300 - Labels, metadata */
--text-muted: #94A3B8 /* Slate 400 - Placeholders, disabled */
```
### Severity Badge Colors
| Severity | Border | Background | Text | Glow Dot |
|----------|--------|------------|------|----------|
| **Critical** | `#EF4444` | `rgba(239, 68, 68, 0.25)` | `#FCA5A5` | `#EF4444` |
| **High** | `#F59E0B` | `rgba(245, 158, 11, 0.25)` | `#FCD34D` | `#F59E0B` |
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
## Layout Structure
### Three-Column Grid Layout
```
┌─────────────────────────────────────────────────────────────┐
│ HEADER & STATS BAR │
│ CVE INTEL | [Stats: Total, Entries, Tickets, Critical] │
├──────────────┬─────────────────────────┬────────────────────┤
│ │ │ │
│ LEFT PANEL │ CENTER PANEL │ RIGHT PANEL │
│ (3 cols) │ (6 cols) │ (3 cols) │
│ │ │ │
│ Knowledge │ Quick CVE Lookup │ Calendar │
│ Base │ Search & Filters │ Widget │
│ - Wiki │ CVE Results List │ │
│ - Docs │ - Expandable cards │ Open Tickets │
│ - Policies │ - Vendor entries │ - Compact list │
│ - Guides │ - Documents │ - Quick stats │
│ │ - JIRA tickets │ │
│ │ │ │
└──────────────┴─────────────────────────┴────────────────────┘
```
### Responsive Breakpoints
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
- **Tablet/Mobile**: Stacked single column
## Component Specifications
### Stat Cards
```css
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
Border: 2px solid [accent-color]
Border Radius: 0.5rem
Padding: 1rem
Top Accent Line: 2px gradient, 0 0 8px glow
Shadow: 0 4px 16px rgba(0, 0, 0, 0.5)
Hover: translateY(-2px), enhanced shadow
```
### Intel Cards (Main Content)
```css
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
Border: 2px solid rgba(14, 165, 233, 0.4)
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6), subtle glow
Hover: Enhanced border (0.5 opacity), lift effect
```
### Buttons
```css
/* Primary */
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))
Border: 1px solid #0EA5E9
Color: #38BDF8
Text Shadow: 0 0 6px rgba(14, 165, 233, 0.2)
/* Hover State */
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.25), rgba(14, 165, 233, 0.2))
Shadow: 0 0 20px rgba(14, 165, 233, 0.25)
Transform: translateY(-1px)
Ripple Effect: 300px radial on click
```
### Input Fields
```css
Background: rgba(30, 41, 59, 0.6)
Border: 1px solid rgba(14, 165, 233, 0.25)
Font: 'JetBrains Mono', monospace
Focus: border #0EA5E9, ring 2px rgba(14, 165, 233, 0.15)
```
### Badges (Status/Severity)
```css
Display: inline-flex
Align Items: center
Gap: 0.5rem
Border: 2px solid [severity-color]
Border Radius: 0.375rem
Padding: 0.375rem 0.875rem
Font: 'JetBrains Mono', 0.75rem, 700, uppercase
Letter Spacing: 0.5px
Glow Dot: 8px circle with pulse animation
```
## Interactions & Animations
### Hover Effects
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
- **Buttons**: Radial ripple expand (300px), slight lift
- **List Items**: Border color shift, background lighten
### Animations
```css
/* Pulse Glow (for dots) */
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.2); }
}
/* Scan Line */
@keyframes scan {
0%, 100% { transform: translateY(-100%); opacity: 0; }
50% { transform: translateY(2000%); opacity: 0.5; }
}
/* Spin (loading) */
@keyframes spin {
to { transform: rotate(360deg); }
}
```
### Transitions
```css
Standard: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
Fast: all 0.2s ease
Ripple: width/height 0.5s
```
## Typography
### Font Families
```css
Primary (UI): 'Outfit', system-ui, sans-serif
Monospace (Data/Code): 'JetBrains Mono', monospace
```
### Font Sizes & Weights
```css
/* Headings */
h1: 2.5rem (40px), 700, monospace
h2: 1.125rem (18px), 600, uppercase, tracking-wider
h3: 1.125rem (18px), 600
/* Body */
Body: 0.875rem (14px), 400
Small: 0.75rem (12px), 400
Labels: 0.75rem (12px), 500, uppercase, tracking-wider
```
### Text Shadows (Headings)
```css
Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0.15)
Badge Text: 0 0 8px rgba([color], 0.5)
```
## Visual Effects
### Shadows
```css
/* Card Elevations */
Level 1: 0 2px 6px rgba(0, 0, 0, 0.3)
Level 2: 0 4px 12px rgba(0, 0, 0, 0.4)
Level 3: 0 8px 24px rgba(0, 0, 0, 0.6)
/* Glows */
Subtle: 0 0 12px rgba([color], 0.12)
Medium: 0 0 20px rgba([color], 0.15)
Strong: 0 0 28px rgba([color], 0.25)
/* Inset Highlights */
Top: inset 0 1px 0 rgba(14, 165, 233, 0.15)
Recessed: inset 0 2px 4px rgba(0, 0, 0, 0.3)
```
### Border Styles
```css
/* Standard Cards */
Border: 1.5-2px solid rgba(14, 165, 233, 0.3-0.4)
/* Accent Panels */
Left Border: 3px solid [accent-color]
/* Vendor/Nested Cards */
Border: 1px solid rgba(14, 165, 233, 0.25)
```
### Gradients
```css
/* Backgrounds */
Card: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
Nested: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9))
/* Accent Lines */
Top Bar: linear-gradient(90deg, transparent, [color], transparent)
/* Grid Background */
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
Size: 20px × 20px
```
## Specific Component Patterns
### Wiki/Knowledge Base Entry
```css
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
Border: 1px solid rgba(16, 185, 129, 0.25)
Padding: 0.75rem
Cursor: pointer
Hover: border-color shift to rgba(16, 185, 129, 0.4)
```
### Calendar Widget
```css
Day Cells:
- Text: white, font-mono, 0.75rem
- Hover: bg rgba(14, 165, 233, 0.2)
- Current Day: bg rgba(14, 165, 233, 0.3), border 1px #0EA5E9
- Other Month: text rgba(148, 163, 184, 0.5)
```
### Ticket Cards (Compact)
```css
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
Border: 1px solid rgba(245, 158, 11, 0.25)
Padding: 0.5rem
Status Badge: Reduced size (0.65rem, 0.25rem padding)
Glow Dot: 6px diameter
```
### CVE Expandable Cards
```css
Header: Clickable, cursor pointer
Collapsed: Show summary (severity, vendor count, doc count)
Expanded: Full description, metadata, vendor entries
Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
Vendor Cards: Nested with reduced opacity borders
```
## Accessibility
### Contrast Ratios
- Primary text on dark: 18.5:1 (AAA)
- Secondary text on dark: 12.3:1 (AAA)
- Accent colors: All meet WCAG AA minimum
### Interactive States
- Focus rings: 2px solid accent color
- Hover: Visible border/background changes
- Active: Transform feedback
### Typography
- Minimum size: 12px (0.75rem)
- Line height: 1.5 for body text
- Letter spacing: Generous for uppercase labels
## Design Principles
1. **Professional Sophistication**: Modern enterprise feel, not arcade
2. **Tactical Intelligence**: Purpose-driven, information-dense
3. **Refined Depth**: Layers and elevation without harsh neon
4. **Purposeful Color**: Accent colors convey meaning (status, severity)
5. **Smooth Interactions**: Polished micro-interactions and transitions
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
7. **Generous Spacing**: Breathing room prevents overwhelming density

Binary file not shown.

1078
docs/testing/run-audit-tests.sh Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
# Authentication Feature - Test Cases
**Tester:** _______________
---
## Pre-Test Setup
- [ ] Backend server running on port 3001
- [ ] Frontend server running on port 3000
- [ ] Database has been set up with `node setup.js`
- [ ] Can access http://[SERVER_IP]:3000 in browser
---
## 1. Login Page Display
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 1.1 | Navigate to app URL when not logged in | Login page displays | |
| 1.2 | Login page shows username field | Field is visible and editable | |
| 1.3 | Login page shows password field | Field is visible and editable | |
| 1.4 | Login page shows "Sign In" button | Button is visible | |
| 1.5 | Default credentials hint is shown | Shows "admin / admin123" | |
---
## 2. Login Functionality
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 2.1 | Login with valid credentials (admin/admin123) | Redirects to dashboard | |
| 2.2 | Login with invalid username | Shows "Invalid username or password" | |
| 2.3 | Login with invalid password | Shows "Invalid username or password" | |
| 2.4 | Login with empty username | Form validation prevents submit | |
| 2.5 | Login with empty password | Form validation prevents submit | |
| 2.6 | Press Enter in password field | Submits form (same as clicking Sign In) | |
---
## 3. Session Persistence
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 3.1 | Refresh page after login | Stays logged in, dashboard displays | |
| 3.2 | Open new browser tab to same URL | Already logged in | |
| 3.3 | Close browser, reopen, navigate to app | Still logged in (within 24hrs) | |
---
## 4. Logout
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 4.1 | Click user menu in header | Dropdown menu appears | |
| 4.2 | Click "Sign Out" in dropdown | Returns to login page | |
| 4.3 | After logout, try to access dashboard URL directly | Redirects to login page | |
| 4.4 | After logout, check browser cookies | session_id cookie is cleared | |
---
## 5. User Menu Display
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 5.1 | User menu shows username | Displays "admin" | |
| 5.2 | User menu shows role | Displays "admin" role | |
| 5.3 | User menu dropdown shows email | Shows admin@localhost | |
| 5.4 | Admin user sees "Manage Users" option | Option is visible | |
---
## 6. Role-Based UI - Admin Role
*Login as: admin/admin123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 6.1 | "Add CVE/Vendor" button in header | Visible | |
| 6.2 | "Upload Document" button on CVE records | Visible | |
| 6.3 | "Delete" button on documents | Visible | |
| 6.4 | "Manage Users" in user menu | Visible | |
| 6.5 | Can open User Management panel | Panel opens | |
---
## 7. User Management (Admin)
*Login as: admin/admin123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 7.1 | Open User Management panel | Shows list of users | |
| 7.2 | Click "Add User" button | Add user form appears | |
| 7.3 | Create user: editor1 / editor1@test.com / password123 / Editor | User created successfully | |
| 7.4 | Create user: viewer1 / viewer1@test.com / password123 / Viewer | User created successfully | |
| 7.5 | Edit existing user (change email) | Changes saved | |
| 7.6 | Toggle user active status | Status changes | |
| 7.7 | Delete a user (not self) | User deleted | |
| 7.8 | Try to delete own account | Error: "Cannot delete your own account" | |
| 7.9 | Try to deactivate own account | Error: "Cannot deactivate your own account" | |
| 7.10 | Try to remove own admin role | Error: "Cannot remove your own admin role" | |
| 7.11 | Create duplicate username | Error: "Username or email already exists" | |
---
## 8. Role-Based UI - Editor Role
*Logout and login as: editor1/password123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 8.1 | "Add CVE/Vendor" button in header | Visible | |
| 8.2 | "Upload Document" button on CVE records | Visible | |
| 8.3 | "Delete" button on documents | NOT visible | |
| 8.4 | "Manage Users" in user menu | NOT visible | |
| 8.5 | Can add a new CVE | CVE created successfully | |
| 8.6 | Can upload a document | Document uploaded successfully | |
---
## 9. Role-Based UI - Viewer Role
*Logout and login as: viewer1/password123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 9.1 | "Add CVE/Vendor" button in header | NOT visible | |
| 9.2 | "Upload Document" button on CVE records | NOT visible | |
| 9.3 | "Delete" button on documents | NOT visible | |
| 9.4 | "Manage Users" in user menu | NOT visible | |
| 9.5 | Can view CVE list | CVEs display correctly | |
| 9.6 | Can view documents (click View) | Documents accessible | |
| 9.7 | Can use Quick CVE Status Check | Search works | |
| 9.8 | Can use filters (vendor, severity) | Filters work | |
---
## 10. Deactivated User
*As admin, deactivate viewer1 account*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 10.1 | Try to login as deactivated user | Error: "Account is disabled" | |
| 10.2 | Reactivate user (as admin) | User can login again | |
---
## 11. Error Handling
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 11.1 | Stop backend, try to login | Shows "Failed to fetch" or connection error | |
| 11.2 | Backend returns 500 error | Error message displayed to user | |
---
## Sign-Off
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Tester | | | |
| Developer | | | |
### Notes / Issues Found:
```
```
### Final Status: [ ] PASS [ ] FAIL

View File

@@ -0,0 +1,222 @@
# Audit Logging Feature - User Acceptance Test Plan
## Test Environment Setup
**Prerequisites:**
- Fresh database via `node backend/setup.js`, OR existing database migrated via `node backend/migrate-audit-log.js`
- Backend running on port 3001
- Frontend running on port 3000
- Three test accounts created:
- `admin` / `admin123` (role: admin)
- `editor1` (role: editor)
- `viewer1` (role: viewer)
**Verify setup:** Run `sqlite3 backend/cve_database.db ".tables"` and confirm `audit_logs` is listed.
---
## 1. Database & Schema
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 1.1 | Fresh install creates table | Run `node setup.js` on a new DB. Query `SELECT sql FROM sqlite_master WHERE name='audit_logs'` | Table exists with columns: id, user_id, username, action, entity_type, entity_id, details, ip_address, created_at | |
| 1.2 | Indexes created | Query `SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_audit%'` | Four indexes: idx_audit_user_id, idx_audit_action, idx_audit_entity_type, idx_audit_created_at | |
| 1.3 | Migration is idempotent | Run `node migrate-audit-log.js` twice on the same DB | Second run prints "already exists, nothing to do". No errors. Backup file created each run. | |
| 1.4 | Migration backs up DB | Run `node migrate-audit-log.js` | Backup file `cve_database_backup_<timestamp>.db` created in backend directory | |
| 1.5 | Setup summary updated | Run `node setup.js` | Console output lists `audit_logs` in the tables line | |
---
## 2. Authentication Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 2.1 | Successful login logged | Log in as `admin`. Query `SELECT * FROM audit_logs WHERE action='login' ORDER BY id DESC LIMIT 1` | Row with user_id=admin's ID, username='admin', action='login', entity_type='auth', details contains `{"role":"admin"}`, ip_address populated | |
| 2.2 | Failed login - wrong password | Attempt login with `admin` / `wrongpass`. Query audit_logs. | Row with action='login_failed', username='admin', details contains `{"reason":"invalid_password"}` | |
| 2.3 | Failed login - unknown user | Attempt login with `nonexistent` / `anypass`. Query audit_logs. | Row with action='login_failed', user_id=NULL, username='nonexistent', details contains `{"reason":"user_not_found"}` | |
| 2.4 | Failed login - disabled account | Disable a user account via admin, then attempt login as that user. Query audit_logs. | Row with action='login_failed', details contains `{"reason":"account_disabled"}` | |
| 2.5 | Logout logged | Log in as admin, then log out. Query audit_logs. | Row with action='logout', entity_type='auth', username='admin' | |
| 2.6 | Login does not block on audit error | Verify login succeeds even if audit_logs table had issues (non-critical path) | Login response returns normally regardless of audit insert result | |
---
## 3. CVE Operation Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 3.1 | CVE create logged | Log in as editor or admin. Add a new CVE (e.g., CVE-2025-TEST-1 / Microsoft / Critical). Query audit_logs. | Row with action='cve_create', entity_type='cve', entity_id='CVE-2025-TEST-1', details contains `{"vendor":"Microsoft","severity":"Critical"}` | |
| 3.2 | CVE status update logged | Update a CVE's status to "Addressed" via the API (`PATCH /api/cves/CVE-2025-TEST-1/status`). Query audit_logs. | Row with action='cve_update_status', entity_id='CVE-2025-TEST-1', details contains `{"status":"Addressed"}` | |
| 3.3 | CVE status update bug fix | Update a CVE's status. Verify the CVE record in the `cves` table. | Status is correctly updated. No SQL error (the old `vendor` reference bug is fixed). | |
| 3.4 | Audit captures acting user | Log in as `editor1`, create a CVE. Query audit_logs. | username='editor1' on the cve_create row | |
---
## 4. Document Operation Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 4.1 | Document upload logged | Upload a document to a CVE via the UI. Query audit_logs. | Row with action='document_upload', entity_type='document', entity_id=CVE ID, details contains vendor, type, and filename | |
| 4.2 | Document delete logged | Delete a document (admin only) via the UI. Query audit_logs. | Row with action='document_delete', entity_type='document', entity_id=document DB ID, details contains file_path | |
| 4.3 | Upload captures file metadata | Upload a file named `advisory.pdf` of type `advisory` for vendor `Cisco`. Query audit_logs. | details = `{"vendor":"Cisco","type":"advisory","filename":"advisory.pdf"}` | |
---
## 5. User Management Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 5.1 | User create logged | As admin, create a new user `testuser` with role `viewer`. Query audit_logs. | Row with action='user_create', entity_type='user', entity_id=new user's ID, details contains `{"created_username":"testuser","role":"viewer"}` | |
| 5.2 | User update logged | As admin, change `testuser`'s role to `editor`. Query audit_logs. | Row with action='user_update', entity_id=testuser's ID, details contains `{"role":"editor"}` | |
| 5.3 | User update - password change | As admin, change `testuser`'s password. Query audit_logs. | details contains `{"password_changed":true}` (password itself is NOT logged) | |
| 5.4 | User update - multiple fields | Change username and role at the same time. Query audit_logs. | details contains both changed fields | |
| 5.5 | User delete logged | As admin, delete `testuser`. Query audit_logs. | Row with action='user_delete', details contains `{"deleted_username":"testuser"}` | |
| 5.6 | User deactivation logged | As admin, set a user's is_active to false. Query audit_logs. | Row with action='user_update', details contains `{"is_active":false}` | |
| 5.7 | Self-delete prevented, no log | As admin, attempt to delete your own account. Query audit_logs. | 400 error returned. NO audit_log entry created for the attempt. | |
---
## 6. API Access Control
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 6.1 | Admin can query audit logs | Log in as admin. `GET /api/audit-logs`. | 200 response with logs array and pagination object | |
| 6.2 | Editor denied audit logs | Log in as editor. `GET /api/audit-logs`. | 403 response with `{"error":"Insufficient permissions"}` | |
| 6.3 | Viewer denied audit logs | Log in as viewer. `GET /api/audit-logs`. | 403 response | |
| 6.4 | Unauthenticated denied | Without a session cookie, `GET /api/audit-logs`. | 401 response | |
| 6.5 | Admin can get actions list | `GET /api/audit-logs/actions` as admin. | 200 response with array of distinct action strings | |
| 6.6 | Non-admin denied actions list | `GET /api/audit-logs/actions` as editor. | 403 response | |
---
## 7. API Filtering & Pagination
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 7.1 | Default pagination | `GET /api/audit-logs` (no params). | Returns up to 25 entries, page=1, correct total count and totalPages | |
| 7.2 | Custom page size | `GET /api/audit-logs?limit=5`. | Returns exactly 5 entries (if >= 5 exist). Pagination reflects limit=5. | |
| 7.3 | Page size capped at 100 | `GET /api/audit-logs?limit=999`. | Returns at most 100 entries per page | |
| 7.4 | Navigate to page 2 | `GET /api/audit-logs?page=2&limit=5`. | Returns entries 6-10 (offset=5). Entries differ from page 1. | |
| 7.5 | Filter by username | `GET /api/audit-logs?user=admin`. | Only entries where username contains "admin" | |
| 7.6 | Partial username match | `GET /api/audit-logs?user=adm`. | Matches "admin" (LIKE search) | |
| 7.7 | Filter by action | `GET /api/audit-logs?action=login`. | Only entries with action='login' (exact match) | |
| 7.8 | Filter by entity type | `GET /api/audit-logs?entityType=auth`. | Only auth-related entries | |
| 7.9 | Filter by date range | `GET /api/audit-logs?startDate=2025-01-01&endDate=2025-12-31`. | Only entries within the date range (inclusive) | |
| 7.10 | Combined filters | `GET /api/audit-logs?user=admin&action=login&entityType=auth`. | Only entries matching ALL filters simultaneously | |
| 7.11 | Empty result set | `GET /api/audit-logs?user=nonexistentuser`. | `{"logs":[],"pagination":{"page":1,"limit":25,"total":0,"totalPages":0}}` | |
| 7.12 | Ordering | Query audit logs without filters. | Entries ordered by created_at DESC (newest first) | |
---
## 8. Frontend - Audit Log Menu Access
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 8.1 | Admin sees Audit Log menu item | Log in as admin. Click user avatar to open dropdown menu. | "Audit Log" option visible with clock icon, positioned between "Manage Users" and "Sign Out" | |
| 8.2 | Editor does NOT see Audit Log | Log in as editor. Click user avatar. | No "Audit Log" or "Manage Users" options visible | |
| 8.3 | Viewer does NOT see Audit Log | Log in as viewer. Click user avatar. | No "Audit Log" or "Manage Users" options visible | |
| 8.4 | Clicking Audit Log opens modal | As admin, click "Audit Log" in the menu. | Modal overlay appears with audit log table. Menu dropdown closes. | |
| 8.5 | Menu closes on outside click | Open the user menu, then click outside the dropdown. | Dropdown closes | |
---
## 9. Frontend - Audit Log Modal
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 9.1 | Modal displays header | Open the Audit Log modal. | Title "Audit Log", subtitle "Track all user actions across the system", X close button visible | |
| 9.2 | Close button works | Click the X button on the modal. | Modal closes, returns to dashboard | |
| 9.3 | Loading state shown | Open the modal (observe briefly). | Spinner with "Loading audit logs..." appears before data loads | |
| 9.4 | Table columns correct | Open modal with data present. | Six columns visible: Time, User, Action, Entity, Details, IP Address | |
| 9.5 | Time formatting | Check the Time column. | Dates display in local format (e.g., "1/29/2026, 3:45:00 PM"), not raw ISO strings | |
| 9.6 | Action badges color-coded | View entries with different action types. | login=green, logout=gray, login_failed=red, cve_create=blue, cve_update_status=yellow, document_upload=purple, document_delete=red, user_create=blue, user_update=yellow, user_delete=red | |
| 9.7 | Entity column format | View entries with entity_type and entity_id. | Shows "cve CVE-2025-TEST-1" or "auth" (no ID for auth entries) | |
| 9.8 | Details column formatting | View an entry with JSON details. | Displays "key: value, key: value" format, not raw JSON | |
| 9.9 | Details truncation | View entry with long details. | Text truncated with ellipsis. Full text visible on hover (title attribute). | |
| 9.10 | IP address display | View entries. | IP addresses shown in monospace font. Null IPs show "-" | |
| 9.11 | Empty state | Apply filters that return no results. | "No audit log entries found." message displayed | |
| 9.12 | Error state | (Simulate: stop backend while modal is open, then apply filters.) | Error icon with error message displayed | |
---
## 10. Frontend - Filters
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 10.1 | Username filter | Type "admin" in username field, click Apply Filters. | Only entries with "admin" in username shown | |
| 10.2 | Action dropdown populated | Click the Action dropdown. | Lists all distinct actions present in the database (from `/api/audit-logs/actions`) | |
| 10.3 | Action filter | Select "login" from Action dropdown, click Apply. | Only login entries shown | |
| 10.4 | Entity type dropdown | Click the Entity Type dropdown. | Lists: auth, cve, document, user | |
| 10.5 | Entity type filter | Select "cve", click Apply. | Only CVE-related entries shown | |
| 10.6 | Date range filter | Set start date to today, set end date to today, click Apply. | Only entries from today shown | |
| 10.7 | Combined filters | Set username="admin", action="login", click Apply. | Only admin login entries shown | |
| 10.8 | Reset button | Set multiple filters, click Reset. | All filter fields cleared. (Note: table does not auto-refresh until Apply is clicked again.) | |
| 10.9 | Apply after reset | Click Reset, then click Apply Filters. | Full unfiltered results shown | |
| 10.10 | Filter resets to page 1 | Navigate to page 2, then apply a filter. | Results start from page 1 | |
---
## 11. Frontend - Pagination
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 11.1 | Pagination info displayed | Open modal with >25 entries. | Shows "Showing 1 - 25 of N entries" and "Page 1 of X" | |
| 11.2 | Next page button | Click the right chevron. | Page advances. Entry range updates. "Page 2 of X" shown. | |
| 11.3 | Previous page button | Navigate to page 2, then click left chevron. | Returns to page 1 | |
| 11.4 | First page - prev disabled | On page 1, check left chevron. | Button is disabled (grayed out, not clickable) | |
| 11.5 | Last page - next disabled | Navigate to the last page. | Right chevron is disabled | |
| 11.6 | Pagination hidden for few entries | Open modal with <= 25 total entries. | No pagination controls shown (totalPages <= 1) | |
| 11.7 | Entry count accuracy | Compare "Showing X - Y of Z" with actual table rows. | Row count matches Y - X + 1. Total Z matches database count. | |
---
## 12. Fire-and-Forget Behavior
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 12.1 | Audit failure does not break login | (Requires code-level test or corrupting audit_logs table temporarily.) Rename audit_logs table, attempt login. | Login succeeds. Console shows "Audit log error:" message. | |
| 12.2 | Audit failure does not break CVE create | With corrupted audit table, create a CVE. | CVE created successfully. Error logged to console only. | |
| 12.3 | Response not delayed by audit | Create a CVE and observe response time. | Response returns immediately; audit insert is non-blocking. | |
---
## 13. Data Integrity
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 13.1 | Audit survives user deletion | Create user, perform actions, delete user. Query audit_logs for that username. | Audit entries remain with the username preserved (denormalized). No foreign key cascade. | |
| 13.2 | Details stored as valid JSON | Query `SELECT details FROM audit_logs WHERE details IS NOT NULL LIMIT 5`. Parse each. | All non-null details values are valid JSON strings | |
| 13.3 | IP address captured | Query entries created via browser. | ip_address field contains the client IP (e.g., `::1` for localhost or `127.0.0.1`) | |
| 13.4 | Timestamps auto-populated | Query entries without explicitly setting created_at. | All rows have a created_at value, not NULL | |
| 13.5 | Null entity_id for auth actions | Query `SELECT * FROM audit_logs WHERE entity_type='auth'`. | entity_id is NULL for login/logout/login_failed entries | |
---
## 14. End-to-End Workflow
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 14.1 | Full user lifecycle | 1. Admin logs in 2. Creates user "testuser2" 3. testuser2 logs in 4. testuser2 creates a CVE 5. Admin updates testuser2's role 6. Admin deletes testuser2 7. Open Audit Log and review | All 6 actions visible in the audit log in reverse chronological order. Each entry has correct user, action, entity, and details. | |
| 14.2 | Filter down to one user's actions | Perform test 14.1, then filter by username="testuser2". | Only testuser2's own actions shown (login, cve_create). Admin actions on testuser2 show admin as the actor. | |
| 14.3 | Security audit trail | Attempt 3 failed logins with wrong password, then succeed. Open Audit Log, filter action="login_failed". | All 3 failed attempts visible with timestamps and IP addresses. Useful for detecting brute force. | |
---
## Test Summary
| Section | Tests | Description |
|---------|-------|-------------|
| 1. Database & Schema | 5 | Table creation, indexes, migration idempotency |
| 2. Auth Logging | 6 | Login success/failure variants, logout |
| 3. CVE Logging | 4 | Create, status update, bug fix verification |
| 4. Document Logging | 3 | Upload, delete, metadata capture |
| 5. User Mgmt Logging | 7 | Create, update, delete, edge cases |
| 6. API Access Control | 6 | Admin-only enforcement on all endpoints |
| 7. API Filtering | 12 | Pagination, filters, combined queries |
| 8. Menu Access | 5 | Role-based UI visibility |
| 9. Modal Display | 12 | Table rendering, formatting, states |
| 10. Frontend Filters | 10 | Filter UI interaction and behavior |
| 11. Pagination UI | 7 | Navigation, boundary conditions |
| 12. Fire-and-Forget | 3 | Non-blocking audit behavior |
| 13. Data Integrity | 5 | Denormalization, JSON, timestamps |
| 14. End-to-End | 3 | Full workflow validation |
| **Total** | **88** | |

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env node
// bu-reassignment-check.js — Check if disappeared findings were reassigned to a different BU
//
// Queries Ivanti for the specific finding IDs that are completely gone from our
// BU-filtered results, using NO filters at all (just the finding IDs).
// If they come back with a different BU, that confirms BU reassignment.
//
// Usage: node backend/scripts/bu-reassignment-check.js
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const { ivantiPost } = require('../helpers/ivantiApi');
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const allResults = [];
// Ivanti's IN filter can handle batches — but let's chunk to be safe
const chunkSize = 50;
for (let i = 0; i < findingIds.length; i += chunkSize) {
const chunk = findingIds.slice(i, i + chunkSize);
const idList = chunk.join(',');
// Query with ONLY the finding ID filter — no BU, no severity, no state
const filters = [
{
field: 'id',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: idList,
caseSensitive: false
}
];
let page = 0;
let totalPages = 1;
do {
const body = {
filters,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
try {
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) {
console.error(` API returned status ${result.status} for chunk starting at ${i}`);
break;
}
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
for (const f of findings) {
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
allResults.push({
id: String(f.id),
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
title: f.title || '',
hostName: f.host?.hostName || '',
ipAddress: f.host?.ipAddress || '',
state: f.status || f.generic_state || '',
bu,
// Check for FP workflow
fpWorkflow: extractFP(f)
});
}
console.error(` Chunk ${Math.floor(i/chunkSize)+1}: page ${page+1}/${totalPages}, ${findings.length} results`);
page++;
} catch (err) {
console.error(` Error querying chunk at ${i}:`, err.message);
break;
}
} while (page < totalPages);
}
return allResults;
}
function extractFP(f) {
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.approvedWorkflows || []),
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const entry = fpBuckets[0];
if (!entry) return null;
return { id: entry.generatedId, state: entry.state };
}
async function main() {
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) {
console.error('IVANTI_API_KEY not set');
process.exit(1);
}
const db = new sqlite3.Database(DB_PATH);
// Get the 124 finding IDs that were completely gone from BU-filtered results
const goneFindings = await dbAll(db,
`SELECT finding_id, last_severity, finding_title, current_state
FROM ivanti_finding_archives
WHERE current_state IN ('ARCHIVED', 'CLOSED')`
);
const goneIds = goneFindings.map(f => f.finding_id);
console.error(`\n=== BU Reassignment Check ===`);
console.error(`Querying Ivanti for ${goneIds.length} disappeared finding IDs (no BU/severity/state filter)...\n`);
const results = await queryByFindingIds(goneIds, apiKey, clientId, skipTls);
const foundMap = new Map(results.map(r => [r.id, r]));
// Categorize
const reassigned = []; // Found with different BU
const sameBU = []; // Found with same BU (STEAM or ACCESS-ENG)
const notFound = []; // Still not found even without filters
const withFP = []; // Has an FP workflow (any state)
for (const arch of goneFindings) {
const found = foundMap.get(arch.finding_id);
if (!found) {
notFound.push(arch);
} else if (found.bu !== 'NTS-AEO-ACCESS-ENG' && found.bu !== 'NTS-AEO-STEAM') {
reassigned.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
} else {
sameBU.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow });
if (found.fpWorkflow) withFP.push({ ...arch, ...found });
}
}
console.log('');
console.log('='.repeat(130));
console.log('BU REASSIGNMENT CHECK RESULTS');
console.log('='.repeat(130));
console.log(`\nREASSIGNED TO DIFFERENT BU: ${reassigned.length} findings`);
console.log('-'.repeat(130));
if (reassigned.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Sev'.padEnd(10) +
'Current Sev'.padEnd(13) +
'Current BU'.padEnd(30) +
'FP Workflow'.padEnd(25) +
'Title'
);
console.log('-'.repeat(130));
for (const f of reassigned) {
const fpStr = f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(10) +
f.currentSeverity.toFixed(2).padEnd(13) +
f.currentBU.padEnd(30) +
fpStr.padEnd(25) +
f.finding_title.substring(0, 40)
);
}
}
console.log(`\nSTILL SAME BU (but missing from filtered results): ${sameBU.length} findings`);
console.log('-'.repeat(130));
if (sameBU.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Sev'.padEnd(10) +
'Current Sev'.padEnd(13) +
'Current BU'.padEnd(30) +
'Current State'.padEnd(15) +
'Title'
);
console.log('-'.repeat(130));
for (const f of sameBU) {
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(10) +
f.currentSeverity.toFixed(2).padEnd(13) +
f.currentBU.padEnd(30) +
f.currentState.padEnd(15) +
f.finding_title.substring(0, 40)
);
}
}
console.log(`\nCOMPLETELY GONE (not found even without any filters): ${notFound.length} findings`);
if (notFound.length > 0 && notFound.length <= 20) {
console.log('-'.repeat(130));
for (const f of notFound) {
console.log(` ${f.finding_id} ${f.last_severity.toFixed(2)} ${f.finding_title.substring(0, 60)}`);
}
}
if (withFP.length > 0) {
console.log(`\nFINDINGS WITH FP WORKFLOWS: ${withFP.length}`);
console.log('-'.repeat(130));
for (const f of withFP) {
const fpStr = f.fpWorkflow ? `${f.fpWorkflow.id} (${f.fpWorkflow.state})` : f.fp ? `${f.fp.id} (${f.fp.state})` : '-';
console.log(` ${f.finding_id || f.id} ${fpStr} ${f.bu || f.currentBU} ${(f.finding_title || f.title || '').substring(0, 50)}`);
}
}
// Summary
console.log('');
console.log('='.repeat(130));
console.log('SUMMARY');
console.log('='.repeat(130));
console.log(` Total disappeared findings checked: ${goneFindings.length}`);
console.log(` Reassigned to different BU: ${reassigned.length}`);
console.log(` Still same BU (unexpected): ${sameBU.length}`);
console.log(` Completely gone from platform: ${notFound.length}`);
console.log(` Have FP workflows: ${withFP.length}`);
if (reassigned.length > 0) {
const buCounts = {};
reassigned.forEach(f => { buCounts[f.currentBU] = (buCounts[f.currentBU] || 0) + 1; });
console.log('\n BU reassignment breakdown:');
for (const [bu, cnt] of Object.entries(buCounts).sort((a, b) => b[1] - a[1])) {
console.log(` ${bu}: ${cnt} findings`);
}
}
if (reassigned.length > goneFindings.length * 0.5) {
console.log('\n VERDICT: BU REASSIGNMENT CONFIRMED.');
} else if (notFound.length > goneFindings.length * 0.5) {
console.log('\n VERDICT: Findings removed from platform entirely (decommission or data purge).');
} else {
console.log('\n VERDICT: Mixed causes — review individual categories above.');
}
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Diagnostic: check alignment between counts history dates and anomaly log dates
// Usage: node backend/scripts/diagnose-chart-alignment.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
function extractDate(ts) {
if (!ts) return '';
return ts.split('T')[0].split(' ')[0];
}
async function main() {
const db = new sqlite3.Database(DB_PATH);
// Get counts history dates (same query as the API)
const countsRows = await dbAll(db,
`SELECT date FROM (
SELECT DATE(recorded_at) AS date,
ROW_NUMBER() OVER (PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC) AS rn
FROM ivanti_counts_history
) WHERE rn = 1 ORDER BY date ASC`
);
const countsDates = new Set(countsRows.map(r => fmtDate(r.date)));
// Get anomaly history (same query as the API)
const anomalyRows = await dbAll(db,
`SELECT sync_timestamp, newly_archived_count, returned_count, return_classification_json
FROM ivanti_sync_anomaly_log
ORDER BY sync_timestamp DESC LIMIT 30`
);
console.log('=== Counts History Dates (last 10) ===');
const lastTen = countsRows.slice(-10);
for (const r of lastTen) {
console.log(` ${r.date}${fmtDate(r.date)}`);
}
console.log('\n=== Anomaly Log Entries with Activity ===');
for (const a of anomalyRows) {
if (a.newly_archived_count === 0 && a.returned_count === 0) continue;
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
const inCounts = countsDates.has(dateKey);
console.log(` ${a.sync_timestamp} → raw="${rawDate}" → key="${dateKey}" | archived=${a.newly_archived_count} returned=${a.returned_count} | in counts: ${inCounts ? 'YES' : '*** NO ***'}`);
}
console.log('\n=== All Anomaly Dates NOT in Counts History ===');
let missingCount = 0;
for (const a of anomalyRows) {
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
if (!countsDates.has(dateKey)) {
console.log(` MISSING: ${a.sync_timestamp} → "${dateKey}" (archived=${a.newly_archived_count}, returned=${a.returned_count})`);
missingCount++;
}
}
if (missingCount === 0) console.log(' (none — all anomaly dates have matching counts history)');
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
// drift-check.js — One-time diagnostic to confirm host-level VRR score drift
//
// Queries Ivanti WITHOUT the severity filter for the same BUs, then cross-
// references the results against our archived finding IDs to see if they
// still exist at lower severity scores.
//
// Usage: node backend/scripts/drift-check.js
//
// Output: prints a comparison table and summary. Does NOT modify cve_database.db
// permanently — uses a temporary in-memory table for the comparison.
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const { ivantiPost } = require('../helpers/ivantiApi');
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
// Same BU filter, NO severity filter, NO state filter — get everything
const ALL_FINDINGS_FILTERS = [
{
field: 'assetCustomAttributes.1550_host_1.value',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
caseSensitive: false
}
];
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve(this);
});
});
}
async function fetchAllFindings(apiKey, clientId, skipTls, state) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const filters = [
...ALL_FINDINGS_FILTERS,
{
field: 'generic_state',
exclusive: false,
operator: 'EXACT',
orWithPrevious: false,
implicitFilters: [],
value: state,
caseSensitive: false
}
];
let allFindings = [];
let page = 0;
let totalPages = 1;
do {
const body = {
filters,
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page,
size: 100
};
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) {
console.error(` API returned status ${result.status} on page ${page}`);
break;
}
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
for (const f of findings) {
allFindings.push({
id: String(f.id),
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
title: f.title || '',
hostName: f.host?.hostName || '',
state
});
}
console.error(` ${state} page ${page + 1}/${totalPages}${allFindings.length} findings so far`);
page++;
} while (page < totalPages);
return allFindings;
}
async function main() {
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) {
console.error('IVANTI_API_KEY not set in backend/.env');
process.exit(1);
}
console.error('=== Drift Check: Querying Ivanti WITHOUT severity filter ===\n');
// Fetch all Open findings (no severity filter)
console.error('Fetching ALL Open findings (no severity filter)...');
const openFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Open');
console.error(` Total Open (all severities): ${openFindings.length}\n`);
// Fetch all Closed findings (no severity filter)
console.error('Fetching ALL Closed findings (no severity filter)...');
const closedFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Closed');
console.error(` Total Closed (all severities): ${closedFindings.length}\n`);
const allFindings = [...openFindings, ...closedFindings];
const findingMap = new Map(allFindings.map(f => [f.id, f]));
console.error(`Total findings across both states: ${allFindings.length}\n`);
// Open the database and get archived finding IDs
const db = new sqlite3.Database(DB_PATH);
const archived = await dbAll(db,
`SELECT finding_id, last_severity, finding_title, host_name, current_state
FROM ivanti_finding_archives
WHERE current_state IN ('ARCHIVED', 'CLOSED')
ORDER BY current_state, last_severity DESC`
);
console.log('');
console.log('='.repeat(120));
console.log('DRIFT CHECK RESULTS');
console.log('='.repeat(120));
console.log('');
// Categorize results
const drifted = []; // Found in API at lower severity (below 8.5)
const stillHigh = []; // Found in API, severity still >= 8.5
const gone = []; // Not found in API at all (any severity)
const stateChanged = []; // Found but in different state
for (const arch of archived) {
const current = findingMap.get(arch.finding_id);
if (!current) {
gone.push(arch);
} else if (current.severity < 8.5) {
drifted.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
} else {
stillHigh.push({ ...arch, currentSeverity: current.severity, currentState: current.state });
}
}
// Print drifted findings
console.log(`CONFIRMED SCORE DRIFT (now below 8.5): ${drifted.length} findings`);
console.log('-'.repeat(120));
if (drifted.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Severity'.padEnd(15) +
'Current Severity'.padEnd(18) +
'Delta'.padEnd(10) +
'Current State'.padEnd(15) +
'Title'
);
console.log('-'.repeat(120));
for (const f of drifted) {
const delta = (f.currentSeverity - f.last_severity).toFixed(2);
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(15) +
f.currentSeverity.toFixed(2).padEnd(18) +
delta.padEnd(10) +
f.currentState.padEnd(15) +
f.finding_title.substring(0, 50)
);
}
}
console.log('');
console.log(`STILL HIGH SEVERITY (>= 8.5, should be in filtered results): ${stillHigh.length} findings`);
console.log('-'.repeat(120));
if (stillHigh.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Severity'.padEnd(15) +
'Current Severity'.padEnd(18) +
'Current State'.padEnd(15) +
'Title'
);
console.log('-'.repeat(120));
for (const f of stillHigh) {
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(15) +
f.currentSeverity.toFixed(2).padEnd(18) +
f.currentState.padEnd(15) +
f.finding_title.substring(0, 50)
);
}
}
console.log('');
console.log(`COMPLETELY GONE (not in API at any severity): ${gone.length} findings`);
console.log('-'.repeat(120));
if (gone.length > 0) {
console.log(
'Finding ID'.padEnd(14) +
'Archive State'.padEnd(15) +
'Last Severity'.padEnd(15) +
'Title'
);
console.log('-'.repeat(120));
for (const f of gone) {
console.log(
f.finding_id.padEnd(14) +
f.current_state.padEnd(15) +
f.last_severity.toFixed(2).padEnd(15) +
f.finding_title.substring(0, 50)
);
}
}
// Summary
console.log('');
console.log('='.repeat(120));
console.log('SUMMARY');
console.log('='.repeat(120));
console.log(` Archived/Closed findings checked: ${archived.length}`);
console.log(` Confirmed score drift (< 8.5): ${drifted.length}`);
console.log(` Still high severity (>= 8.5): ${stillHigh.length}`);
console.log(` Completely gone from API: ${gone.length}`);
console.log('');
if (drifted.length > 0) {
const avgDelta = drifted.reduce((sum, f) => sum + (f.currentSeverity - f.last_severity), 0) / drifted.length;
const minNew = Math.min(...drifted.map(f => f.currentSeverity));
const maxNew = Math.max(...drifted.map(f => f.currentSeverity));
console.log(` Score drift range: ${minNew.toFixed(2)} ${maxNew.toFixed(2)} (avg delta: ${avgDelta.toFixed(2)})`);
}
if (drifted.length > archived.length * 0.5) {
console.log('\n VERDICT: Host-level VRR score drift CONFIRMED.');
console.log(' The majority of disappeared findings still exist in Ivanti but at lower severity scores.');
} else if (drifted.length > 0) {
console.log('\n VERDICT: Partial score drift detected. Some findings drifted, others may have been removed.');
} else if (gone.length > archived.length * 0.5) {
console.log('\n VERDICT: Score drift NOT confirmed. Most findings are completely gone from the API.');
console.log(' This suggests BU reassignment, host decommission, or a platform-side data issue.');
}
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
// export-reassigned-findings.js — Generate an xlsx with findings reassigned to SDIT-CSD-ITLS-PIES
//
// Pulls data from the archive database and the BU reassignment check results.
// Outputs to docs/reassigned-findings-2026-04-24.xlsx
//
// Usage: node backend/scripts/export-reassigned-findings.js
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const XLSX = require('xlsx');
const { ivantiPost } = require('../helpers/ivantiApi');
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
const OUTPUT_PATH = path.join(__dirname, '..', '..', 'docs', 'reassigned-findings-2026-04-24.xlsx');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) {
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const results = new Map();
const chunkSize = 50;
for (let i = 0; i < findingIds.length; i += chunkSize) {
const chunk = findingIds.slice(i, i + chunkSize);
const idList = chunk.join(',');
const filters = [{
field: 'id', exclusive: false, operator: 'IN',
orWithPrevious: false, implicitFilters: [],
value: idList, caseSensitive: false
}];
let page = 0;
let totalPages = 1;
do {
try {
const body = { filters, projection: 'internal', sort: [{ field: 'severity', direction: 'ASC' }], page, size: 100 };
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
if (result.status !== 200) break;
const data = JSON.parse(result.body);
totalPages = data.page?.totalPages || 1;
const findings = data._embedded?.hostFindings || [];
for (const f of findings) {
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.approvedWorkflows || []), ...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const fp = fpBuckets[0] || null;
results.set(String(f.id), {
bu,
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
state: f.status || '',
fpId: fp ? fp.generatedId : '',
fpState: fp ? fp.state : '',
hostName: f.host?.hostName || '',
ipAddress: f.host?.ipAddress || '',
title: f.title || '',
});
}
page++;
} catch (err) {
console.error(` Error on batch at ${i}:`, err.message);
break;
}
} while (page < totalPages);
}
return results;
}
async function main() {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
const db = new sqlite3.Database(DB_PATH);
// Get all archived/closed findings from the archive
const archived = await dbAll(db,
`SELECT finding_id, last_severity, finding_title, host_name, ip_address, current_state,
DATE(first_archived_at) as archived_date, DATE(last_transition_at) as last_transition_date
FROM ivanti_finding_archives
WHERE current_state IN ('ARCHIVED', 'CLOSED')
ORDER BY current_state, last_severity DESC`
);
const ids = archived.map(a => a.finding_id);
console.log(`Querying Ivanti for ${ids.length} findings...`);
const currentData = await queryByFindingIds(ids, apiKey, clientId, skipTls);
// Build rows for each sheet
const reassignedRows = [];
const goneRows = [];
const sameBuRows = [];
for (const arch of archived) {
const current = currentData.get(arch.finding_id);
if (!current) {
goneRows.push({
'Finding ID': arch.finding_id,
'Title': arch.finding_title,
'Last Severity': arch.last_severity,
'Host': arch.host_name,
'IP Address': arch.ip_address,
'Archive State': arch.current_state,
'Archived Date': arch.archived_date,
'Status': 'Gone from platform',
});
} else if (current.bu !== 'NTS-AEO-ACCESS-ENG' && current.bu !== 'NTS-AEO-STEAM') {
reassignedRows.push({
'Finding ID': arch.finding_id,
'Title': current.title || arch.finding_title,
'Last Severity (STEAM)': arch.last_severity,
'Current Severity': current.severity,
'Host': current.hostName || arch.host_name,
'IP Address': current.ipAddress || arch.ip_address,
'Previous BU': 'NTS-AEO-STEAM / ACCESS-ENG',
'Current BU': current.bu,
'Current State': current.state,
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
'Archive State': arch.current_state,
'Archived Date': arch.archived_date,
});
} else {
sameBuRows.push({
'Finding ID': arch.finding_id,
'Title': current.title || arch.finding_title,
'Severity': current.severity,
'Host': current.hostName || arch.host_name,
'IP Address': current.ipAddress || arch.ip_address,
'BU': current.bu,
'Current State': current.state,
'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '',
'Archive State': arch.current_state,
});
}
}
// Create workbook
const wb = XLSX.utils.book_new();
// Sheet 1: Reassigned findings
const ws1 = XLSX.utils.json_to_sheet(reassignedRows);
// Set column widths
ws1['!cols'] = [
{ wch: 14 }, { wch: 55 }, { wch: 18 }, { wch: 16 },
{ wch: 30 }, { wch: 16 }, { wch: 28 }, { wch: 24 },
{ wch: 14 }, { wch: 24 }, { wch: 14 }, { wch: 14 },
];
XLSX.utils.book_append_sheet(wb, ws1, 'Reassigned to SDIT-PIES');
// Sheet 2: Gone from platform
if (goneRows.length > 0) {
const ws2 = XLSX.utils.json_to_sheet(goneRows);
ws2['!cols'] = [
{ wch: 14 }, { wch: 55 }, { wch: 14 }, { wch: 30 },
{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 20 },
];
XLSX.utils.book_append_sheet(wb, ws2, 'Gone from Platform');
}
// Sheet 3: Still same BU
if (sameBuRows.length > 0) {
const ws3 = XLSX.utils.json_to_sheet(sameBuRows);
ws3['!cols'] = [
{ wch: 14 }, { wch: 55 }, { wch: 10 }, { wch: 30 },
{ wch: 16 }, { wch: 24 }, { wch: 14 }, { wch: 24 }, { wch: 14 },
];
XLSX.utils.book_append_sheet(wb, ws3, 'Still Same BU');
}
// Write file
XLSX.writeFile(wb, OUTPUT_PATH);
console.log(`\nExported to: ${OUTPUT_PATH}`);
console.log(` Reassigned: ${reassignedRows.length}`);
console.log(` Gone: ${goneRows.length}`);
console.log(` Same BU: ${sameBuRows.length}`);
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});