Add HTML report generation with visual charts
- 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>
This commit is contained in:
528
report.py
Normal file
528
report.py
Normal file
@@ -0,0 +1,528 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user