Files
cve/report.py
cproudlock 4a19532b9e 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>
2025-12-18 12:57:10 -05:00

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