- Professional styled HTML output with summary cards - Bar charts for patch categories - Tables for org breakdown and critical items - Device attention list with critical badges - Generates TXT, CSV, and HTML simultaneously 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
529 lines
23 KiB
Python
529 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
NinjaOne CVE Comprehensive Report
|
|
Generates detailed breakdown by category, organization, and device.
|
|
"""
|
|
|
|
import requests
|
|
from collections import defaultdict
|
|
from datetime import datetime
|
|
import csv
|
|
import json
|
|
|
|
try:
|
|
from config import NINJAONE_CONFIG
|
|
except ImportError:
|
|
print("Error: Copy config.example.py to config.py and add your credentials")
|
|
exit(1)
|
|
|
|
|
|
class NinjaOneReporter:
|
|
"""Generate comprehensive CVE reports from NinjaOne"""
|
|
|
|
def __init__(self):
|
|
self.config = NINJAONE_CONFIG
|
|
self.access_token = None
|
|
self.organizations = {}
|
|
self.devices = {}
|
|
|
|
def authenticate(self):
|
|
"""Get OAuth token"""
|
|
response = requests.post(
|
|
self.config['token_url'],
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
data={
|
|
'grant_type': 'client_credentials',
|
|
'client_id': self.config['client_id'],
|
|
'client_secret': self.config['client_secret'],
|
|
'scope': self.config['scope']
|
|
}
|
|
)
|
|
response.raise_for_status()
|
|
self.access_token = response.json()['access_token']
|
|
print("Authenticated successfully")
|
|
|
|
def api_get(self, endpoint, params=None):
|
|
"""Make API GET request"""
|
|
headers = {'Authorization': f'Bearer {self.access_token}'}
|
|
url = f"{self.config['base_url']}/api/v2{endpoint}"
|
|
response = requests.get(url, headers=headers, params=params)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def load_organizations(self):
|
|
"""Load all organizations into lookup dict"""
|
|
print("Loading organizations...")
|
|
orgs = self.api_get('/organizations')
|
|
for org in orgs:
|
|
self.organizations[org['id']] = org['name']
|
|
print(f" Loaded {len(self.organizations)} organizations")
|
|
|
|
def load_devices(self):
|
|
"""Load all devices into lookup dict"""
|
|
print("Loading devices...")
|
|
devices = self.api_get('/devices-detailed')
|
|
for device in devices:
|
|
self.devices[device['id']] = {
|
|
'name': device.get('systemName') or device.get('dnsName') or f"Device-{device['id']}",
|
|
'org_id': device.get('organizationId'),
|
|
'org_name': self.organizations.get(device.get('organizationId'), 'Unknown'),
|
|
'os': device.get('os', {}).get('name', 'Unknown') if isinstance(device.get('os'), dict) else 'Unknown'
|
|
}
|
|
print(f" Loaded {len(self.devices)} devices")
|
|
|
|
def get_all_patches(self):
|
|
"""Get all OS and software patches"""
|
|
print("Loading OS patches...")
|
|
os_result = self.api_get('/queries/os-patches')
|
|
os_patches = os_result.get('results', []) if isinstance(os_result, dict) else os_result
|
|
|
|
print("Loading software patches...")
|
|
sw_result = self.api_get('/queries/software-patches')
|
|
sw_patches = sw_result.get('results', []) if isinstance(sw_result, dict) else sw_result
|
|
|
|
print(f" Found {len(os_patches)} OS patches, {len(sw_patches)} software patches")
|
|
return os_patches, sw_patches
|
|
|
|
def categorize_patch(self, patch, patch_type):
|
|
"""Categorize a patch by its source/type"""
|
|
title = patch.get('title', '').lower()
|
|
|
|
if patch_type == 'os':
|
|
return 'Windows/OS Updates'
|
|
|
|
# Application categories
|
|
if any(x in title for x in ['chrome', 'firefox', 'edge', 'opera', 'safari']):
|
|
return 'Web Browsers'
|
|
elif any(x in title for x in ['adobe', 'acrobat', 'reader', 'flash']):
|
|
return 'Adobe Products'
|
|
elif any(x in title for x in ['java', 'jre', 'jdk', 'corretto']):
|
|
return 'Java/Runtime'
|
|
elif any(x in title for x in ['office', '365', 'word', 'excel', 'outlook']):
|
|
return 'Microsoft Office'
|
|
elif any(x in title for x in ['zoom', 'teams', 'webex', 'slack', 'gotomeeting']):
|
|
return 'Communication Apps'
|
|
elif any(x in title for x in ['vnc', 'tightvnc', 'ultravnc', 'teamviewer', 'anydesk']):
|
|
return 'Remote Access Tools'
|
|
elif any(x in title for x in ['7-zip', 'winrar', 'winzip']):
|
|
return 'Compression Tools'
|
|
elif any(x in title for x in ['vlc', 'media', 'player']):
|
|
return 'Media Players'
|
|
elif any(x in title for x in ['notepad', 'sublime', 'vscode', 'visual studio code']):
|
|
return 'Text Editors/IDEs'
|
|
elif any(x in title for x in ['putty', 'filezilla', 'winscp']):
|
|
return 'Network/Transfer Tools'
|
|
elif any(x in title for x in ['keepass', 'password']):
|
|
return 'Password Managers'
|
|
elif any(x in title for x in ['foxit', 'pdf']):
|
|
return 'PDF Tools'
|
|
elif any(x in title for x in ['libre', 'open office', 'openoffice']):
|
|
return 'Office Alternatives'
|
|
else:
|
|
return 'Other Applications'
|
|
|
|
def generate_report(self):
|
|
"""Generate comprehensive report"""
|
|
self.authenticate()
|
|
self.load_organizations()
|
|
self.load_devices()
|
|
os_patches, sw_patches = self.get_all_patches()
|
|
|
|
# Data structures for analysis
|
|
by_category = defaultdict(lambda: {'total': 0, 'critical': 0, 'devices': set()})
|
|
by_org = defaultdict(lambda: {'total': 0, 'critical': 0, 'os': 0, 'software': 0, 'devices': set()})
|
|
critical_items = []
|
|
device_details = []
|
|
|
|
# Process OS patches
|
|
for patch in os_patches:
|
|
device_id = patch.get('deviceId')
|
|
device = self.devices.get(device_id, {})
|
|
org_name = device.get('org_name', 'Unknown')
|
|
impact = patch.get('impact', 'unknown').lower()
|
|
|
|
category = 'Windows/OS Updates'
|
|
by_category[category]['total'] += 1
|
|
by_category[category]['devices'].add(device_id)
|
|
|
|
by_org[org_name]['total'] += 1
|
|
by_org[org_name]['os'] += 1
|
|
by_org[org_name]['devices'].add(device_id)
|
|
|
|
if impact == 'critical':
|
|
by_category[category]['critical'] += 1
|
|
by_org[org_name]['critical'] += 1
|
|
critical_items.append({
|
|
'type': 'OS',
|
|
'title': patch.get('title', 'Unknown'),
|
|
'device_id': device_id,
|
|
'device_name': device.get('name', 'Unknown'),
|
|
'org': org_name,
|
|
'status': patch.get('status', 'Unknown')
|
|
})
|
|
|
|
device_details.append({
|
|
'type': 'OS',
|
|
'category': category,
|
|
'title': patch.get('title', 'Unknown'),
|
|
'impact': impact,
|
|
'status': patch.get('status', 'Unknown'),
|
|
'device_id': device_id,
|
|
'device_name': device.get('name', 'Unknown'),
|
|
'organization': org_name
|
|
})
|
|
|
|
# Process software patches
|
|
for patch in sw_patches:
|
|
device_id = patch.get('deviceId')
|
|
device = self.devices.get(device_id, {})
|
|
org_name = device.get('org_name', 'Unknown')
|
|
impact = patch.get('impact', 'unknown').lower()
|
|
|
|
category = self.categorize_patch(patch, 'software')
|
|
by_category[category]['total'] += 1
|
|
by_category[category]['devices'].add(device_id)
|
|
|
|
by_org[org_name]['total'] += 1
|
|
by_org[org_name]['software'] += 1
|
|
by_org[org_name]['devices'].add(device_id)
|
|
|
|
if impact == 'critical':
|
|
by_category[category]['critical'] += 1
|
|
by_org[org_name]['critical'] += 1
|
|
critical_items.append({
|
|
'type': 'Software',
|
|
'title': patch.get('title', 'Unknown'),
|
|
'device_id': device_id,
|
|
'device_name': device.get('name', 'Unknown'),
|
|
'org': org_name,
|
|
'status': patch.get('status', 'Unknown')
|
|
})
|
|
|
|
device_details.append({
|
|
'type': 'Software',
|
|
'category': category,
|
|
'title': patch.get('title', 'Unknown'),
|
|
'impact': impact,
|
|
'status': patch.get('status', 'Unknown'),
|
|
'device_id': device_id,
|
|
'device_name': device.get('name', 'Unknown'),
|
|
'organization': org_name
|
|
})
|
|
|
|
# Generate text report
|
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
report = []
|
|
report.append("=" * 70)
|
|
report.append("NINJAONE CVE COMPREHENSIVE REPORT")
|
|
report.append(f"Generated: {timestamp}")
|
|
report.append("=" * 70)
|
|
|
|
report.append(f"\nTOTAL ORGANIZATIONS: {len(self.organizations)}")
|
|
report.append(f"TOTAL DEVICES: {len(self.devices)}")
|
|
report.append(f"TOTAL OS PATCHES: {len(os_patches)}")
|
|
report.append(f"TOTAL SOFTWARE PATCHES: {len(sw_patches)}")
|
|
report.append(f"TOTAL CRITICAL ITEMS: {len(critical_items)}")
|
|
|
|
# Section 1: By Category
|
|
report.append("\n" + "=" * 70)
|
|
report.append("SECTION 1: PATCHES BY CATEGORY (Most Common CVE Causes)")
|
|
report.append("=" * 70)
|
|
|
|
sorted_categories = sorted(by_category.items(), key=lambda x: x[1]['total'], reverse=True)
|
|
report.append(f"\n{'Category':<30} {'Total':>8} {'Critical':>10} {'Devices':>10}")
|
|
report.append("-" * 60)
|
|
for category, data in sorted_categories:
|
|
report.append(f"{category:<30} {data['total']:>8} {data['critical']:>10} {len(data['devices']):>10}")
|
|
|
|
# Section 2: By Organization
|
|
report.append("\n" + "=" * 70)
|
|
report.append("SECTION 2: PATCHES BY ORGANIZATION")
|
|
report.append("=" * 70)
|
|
|
|
sorted_orgs = sorted(by_org.items(), key=lambda x: x[1]['total'], reverse=True)
|
|
report.append(f"\n{'Organization':<35} {'Total':>7} {'Crit':>6} {'OS':>6} {'SW':>6} {'Devices':>8}")
|
|
report.append("-" * 70)
|
|
for org, data in sorted_orgs:
|
|
org_display = org[:33] + '..' if len(org) > 35 else org
|
|
report.append(f"{org_display:<35} {data['total']:>7} {data['critical']:>6} {data['os']:>6} {data['software']:>6} {len(data['devices']):>8}")
|
|
|
|
# Section 3: Critical Items with Device Details
|
|
report.append("\n" + "=" * 70)
|
|
report.append("SECTION 3: CRITICAL ITEMS (Requires Immediate Attention)")
|
|
report.append("=" * 70)
|
|
|
|
if critical_items:
|
|
# Group by title
|
|
critical_by_title = defaultdict(list)
|
|
for item in critical_items:
|
|
critical_by_title[item['title']].append(item)
|
|
|
|
for title, items in sorted(critical_by_title.items(), key=lambda x: len(x[1]), reverse=True):
|
|
report.append(f"\n {title} ({len(items)} devices)")
|
|
for item in items[:10]: # Show first 10 devices per title
|
|
report.append(f" - {item['device_name']} ({item['org']}) - {item['status']}")
|
|
if len(items) > 10:
|
|
report.append(f" ... and {len(items) - 10} more devices")
|
|
else:
|
|
report.append("\n No critical items found")
|
|
|
|
# Section 4: Devices needing attention (most patches)
|
|
report.append("\n" + "=" * 70)
|
|
report.append("SECTION 4: DEVICES NEEDING MOST ATTENTION")
|
|
report.append("=" * 70)
|
|
|
|
device_patch_count = defaultdict(lambda: {'total': 0, 'critical': 0, 'name': '', 'org': ''})
|
|
for detail in device_details:
|
|
did = detail['device_id']
|
|
device_patch_count[did]['total'] += 1
|
|
device_patch_count[did]['name'] = detail['device_name']
|
|
device_patch_count[did]['org'] = detail['organization']
|
|
if detail['impact'] == 'critical':
|
|
device_patch_count[did]['critical'] += 1
|
|
|
|
sorted_devices = sorted(device_patch_count.items(), key=lambda x: (x[1]['critical'], x[1]['total']), reverse=True)
|
|
report.append(f"\n{'Device Name':<30} {'Organization':<25} {'Total':>7} {'Critical':>10}")
|
|
report.append("-" * 75)
|
|
for device_id, data in sorted_devices[:30]: # Top 30 devices
|
|
name = data['name'][:28] + '..' if len(data['name']) > 30 else data['name']
|
|
org = data['org'][:23] + '..' if len(data['org']) > 25 else data['org']
|
|
report.append(f"{name:<30} {org:<25} {data['total']:>7} {data['critical']:>10}")
|
|
|
|
report_text = "\n".join(report)
|
|
|
|
# Save text report
|
|
report_file = f"cve_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
|
with open(report_file, 'w') as f:
|
|
f.write(report_text)
|
|
print(f"\nText report saved to: {report_file}")
|
|
|
|
# Save CSV for detailed analysis
|
|
csv_file = f"cve_details_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
with open(csv_file, 'w', newline='') as f:
|
|
writer = csv.DictWriter(f, fieldnames=['type', 'category', 'title', 'impact', 'status', 'device_id', 'device_name', 'organization'])
|
|
writer.writeheader()
|
|
writer.writerows(device_details)
|
|
print(f"CSV details saved to: {csv_file}")
|
|
|
|
# Generate HTML report
|
|
html_file = f"cve_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
|
html_content = self.generate_html_report(
|
|
timestamp, sorted_categories, sorted_orgs, critical_by_title,
|
|
sorted_devices, os_patches, sw_patches, critical_items
|
|
)
|
|
with open(html_file, 'w') as f:
|
|
f.write(html_content)
|
|
print(f"HTML report saved to: {html_file}")
|
|
|
|
print(report_text)
|
|
return report_text
|
|
|
|
def generate_html_report(self, timestamp, sorted_categories, sorted_orgs, critical_by_title, sorted_devices, os_patches, sw_patches, critical_items):
|
|
"""Generate HTML report"""
|
|
html = f'''<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CVE Report - {timestamp}</title>
|
|
<style>
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
margin: 0; padding: 20px; background: #f5f5f5; color: #333;
|
|
}}
|
|
.container {{ max-width: 1400px; margin: 0 auto; }}
|
|
h1 {{ color: #1a365d; border-bottom: 3px solid #3182ce; padding-bottom: 10px; }}
|
|
h2 {{ color: #2c5282; margin-top: 30px; border-left: 4px solid #3182ce; padding-left: 15px; }}
|
|
.summary-cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
|
|
.card {{
|
|
background: white; padding: 20px; border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center;
|
|
}}
|
|
.card h3 {{ margin: 0 0 10px 0; color: #666; font-size: 14px; text-transform: uppercase; }}
|
|
.card .number {{ font-size: 36px; font-weight: bold; color: #2c5282; }}
|
|
.card.critical .number {{ color: #c53030; }}
|
|
.card.warning .number {{ color: #d69e2e; }}
|
|
table {{
|
|
width: 100%; border-collapse: collapse; background: white;
|
|
margin: 15px 0; border-radius: 8px; overflow: hidden;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}}
|
|
th {{ background: #2c5282; color: white; padding: 12px 15px; text-align: left; font-weight: 600; }}
|
|
td {{ padding: 10px 15px; border-bottom: 1px solid #e2e8f0; }}
|
|
tr:hover {{ background: #f7fafc; }}
|
|
tr:last-child td {{ border-bottom: none; }}
|
|
.badge {{
|
|
display: inline-block; padding: 3px 8px; border-radius: 12px;
|
|
font-size: 12px; font-weight: 600;
|
|
}}
|
|
.badge-critical {{ background: #fed7d7; color: #c53030; }}
|
|
.badge-high {{ background: #feebc8; color: #c05621; }}
|
|
.badge-medium {{ background: #fefcbf; color: #975a16; }}
|
|
.badge-low {{ background: #c6f6d5; color: #276749; }}
|
|
.badge-approved {{ background: #c6f6d5; color: #276749; }}
|
|
.badge-rejected {{ background: #fed7d7; color: #c53030; }}
|
|
.badge-manual {{ background: #e9d8fd; color: #6b46c1; }}
|
|
.progress-bar {{
|
|
height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden;
|
|
}}
|
|
.progress-fill {{ height: 100%; background: #3182ce; }}
|
|
.progress-fill.critical {{ background: #c53030; }}
|
|
.section {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
|
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
|
|
@media (max-width: 900px) {{ .two-col {{ grid-template-columns: 1fr; }} }}
|
|
.timestamp {{ color: #718096; font-size: 14px; }}
|
|
.chart-bar {{ display: flex; align-items: center; margin: 8px 0; }}
|
|
.chart-label {{ width: 200px; font-size: 14px; }}
|
|
.chart-value {{ width: 60px; text-align: right; font-weight: 600; margin-left: 10px; }}
|
|
.chart-track {{ flex: 1; height: 24px; background: #e2e8f0; border-radius: 4px; overflow: hidden; }}
|
|
.chart-fill {{ height: 100%; background: linear-gradient(90deg, #3182ce, #63b3ed); display: flex; align-items: center; justify-content: flex-end; padding-right: 8px; color: white; font-size: 12px; }}
|
|
.chart-fill.has-critical {{ background: linear-gradient(90deg, #c53030, #fc8181); }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🛡️ NinjaOne CVE Report</h1>
|
|
<p class="timestamp">Generated: {timestamp}</p>
|
|
|
|
<div class="summary-cards">
|
|
<div class="card">
|
|
<h3>Organizations</h3>
|
|
<div class="number">{len(self.organizations)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Total Devices</h3>
|
|
<div class="number">{len(self.devices)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>OS Patches</h3>
|
|
<div class="number">{len(os_patches)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Software Patches</h3>
|
|
<div class="number">{len(sw_patches)}</div>
|
|
</div>
|
|
<div class="card critical">
|
|
<h3>Critical Items</h3>
|
|
<div class="number">{len(critical_items)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>📊 Patches by Category</h2>
|
|
<div class="section">
|
|
<p>Understanding what types of software are causing the most CVE exposure:</p>
|
|
'''
|
|
# Category chart
|
|
max_total = max(d['total'] for _, d in sorted_categories) if sorted_categories else 1
|
|
for category, data in sorted_categories:
|
|
pct = (data['total'] / max_total) * 100
|
|
has_critical = 'has-critical' if data['critical'] > 0 else ''
|
|
critical_text = f" ({data['critical']} critical)" if data['critical'] > 0 else ""
|
|
html += f'''
|
|
<div class="chart-bar">
|
|
<div class="chart-label">{category}</div>
|
|
<div class="chart-track">
|
|
<div class="chart-fill {has_critical}" style="width: {pct}%">{data['total']}{critical_text}</div>
|
|
</div>
|
|
<div class="chart-value">{len(data['devices'])} dev</div>
|
|
</div>'''
|
|
|
|
html += '''
|
|
</div>
|
|
|
|
<h2>🏢 Patches by Organization</h2>
|
|
<div class="section">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Organization</th>
|
|
<th>Total</th>
|
|
<th>Critical</th>
|
|
<th>OS</th>
|
|
<th>Software</th>
|
|
<th>Devices</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
'''
|
|
for org, data in sorted_orgs[:20]:
|
|
critical_badge = f'<span class="badge badge-critical">{data["critical"]}</span>' if data['critical'] > 0 else '0'
|
|
html += f'''
|
|
<tr>
|
|
<td><strong>{org}</strong></td>
|
|
<td>{data['total']}</td>
|
|
<td>{critical_badge}</td>
|
|
<td>{data['os']}</td>
|
|
<td>{data['software']}</td>
|
|
<td>{len(data['devices'])}</td>
|
|
</tr>'''
|
|
|
|
html += '''
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2>🚨 Critical Items Requiring Immediate Attention</h2>
|
|
<div class="section">
|
|
'''
|
|
if critical_by_title:
|
|
html += '<table><thead><tr><th>Software</th><th>Affected Devices</th><th>Details</th></tr></thead><tbody>'
|
|
for title, items in sorted(critical_by_title.items(), key=lambda x: len(x[1]), reverse=True):
|
|
device_list = ', '.join([f"{i['device_name']}" for i in items[:5]])
|
|
if len(items) > 5:
|
|
device_list += f' ... +{len(items)-5} more'
|
|
orgs = list(set([i['org'] for i in items]))
|
|
html += f'''
|
|
<tr>
|
|
<td><strong>{title}</strong></td>
|
|
<td><span class="badge badge-critical">{len(items)} devices</span></td>
|
|
<td>{device_list}<br><small style="color:#666">Orgs: {", ".join(orgs[:3])}</small></td>
|
|
</tr>'''
|
|
html += '</tbody></table>'
|
|
else:
|
|
html += '<p style="color: #276749;">✅ No critical items found!</p>'
|
|
|
|
html += '''
|
|
</div>
|
|
|
|
<h2>💻 Devices Needing Most Attention</h2>
|
|
<div class="section">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Device</th>
|
|
<th>Organization</th>
|
|
<th>Total Patches</th>
|
|
<th>Critical</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
'''
|
|
for device_id, data in sorted_devices[:25]:
|
|
critical_badge = f'<span class="badge badge-critical">{data["critical"]}</span>' if data['critical'] > 0 else '0'
|
|
html += f'''
|
|
<tr>
|
|
<td><strong>{data['name']}</strong></td>
|
|
<td>{data['org']}</td>
|
|
<td>{data['total']}</td>
|
|
<td>{critical_badge}</td>
|
|
</tr>'''
|
|
|
|
html += '''
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<footer style="text-align: center; padding: 20px; color: #718096; font-size: 14px;">
|
|
Generated by NinjaOne CVE Report Tool
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>'''
|
|
return html
|
|
|
|
|
|
if __name__ == '__main__':
|
|
reporter = NinjaOneReporter()
|
|
reporter.generate_report()
|