Add patches needing approval section to reports
- Add Section 4 showing MANUAL status patches grouped by org/device - Fix OS patch field mapping (name/severity instead of title/impact) - Add pending approval count to HTML summary cards - Update .gitignore to exclude HTML and CSV report files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@ ENV/
|
|||||||
|
|
||||||
# Reports
|
# Reports
|
||||||
cve_report_*.txt
|
cve_report_*.txt
|
||||||
|
cve_report_*.html
|
||||||
|
cve_details_*.csv
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|||||||
94
report.py
94
report.py
@@ -135,11 +135,15 @@ class NinjaOneReporter:
|
|||||||
device_details = []
|
device_details = []
|
||||||
|
|
||||||
# Process OS patches
|
# Process OS patches
|
||||||
|
# OS patches use 'name' and 'severity' fields
|
||||||
for patch in os_patches:
|
for patch in os_patches:
|
||||||
device_id = patch.get('deviceId')
|
device_id = patch.get('deviceId')
|
||||||
device = self.devices.get(device_id, {})
|
device = self.devices.get(device_id, {})
|
||||||
org_name = device.get('org_name', 'Unknown')
|
org_name = device.get('org_name', 'Unknown')
|
||||||
impact = patch.get('impact', 'unknown').lower()
|
# OS patches use 'severity' not 'impact'
|
||||||
|
impact = patch.get('severity', patch.get('impact', 'unknown')).lower()
|
||||||
|
# OS patches use 'name' not 'title'
|
||||||
|
title = patch.get('name', patch.get('title', 'Unknown'))
|
||||||
|
|
||||||
category = 'Windows/OS Updates'
|
category = 'Windows/OS Updates'
|
||||||
by_category[category]['total'] += 1
|
by_category[category]['total'] += 1
|
||||||
@@ -154,7 +158,7 @@ class NinjaOneReporter:
|
|||||||
by_org[org_name]['critical'] += 1
|
by_org[org_name]['critical'] += 1
|
||||||
critical_items.append({
|
critical_items.append({
|
||||||
'type': 'OS',
|
'type': 'OS',
|
||||||
'title': patch.get('title', 'Unknown'),
|
'title': title,
|
||||||
'device_id': device_id,
|
'device_id': device_id,
|
||||||
'device_name': device.get('name', 'Unknown'),
|
'device_name': device.get('name', 'Unknown'),
|
||||||
'org': org_name,
|
'org': org_name,
|
||||||
@@ -164,7 +168,7 @@ class NinjaOneReporter:
|
|||||||
device_details.append({
|
device_details.append({
|
||||||
'type': 'OS',
|
'type': 'OS',
|
||||||
'category': category,
|
'category': category,
|
||||||
'title': patch.get('title', 'Unknown'),
|
'title': title,
|
||||||
'impact': impact,
|
'impact': impact,
|
||||||
'status': patch.get('status', 'Unknown'),
|
'status': patch.get('status', 'Unknown'),
|
||||||
'device_id': device_id,
|
'device_id': device_id,
|
||||||
@@ -267,9 +271,38 @@ class NinjaOneReporter:
|
|||||||
else:
|
else:
|
||||||
report.append("\n No critical items found")
|
report.append("\n No critical items found")
|
||||||
|
|
||||||
# Section 4: Devices needing attention (most patches)
|
# Section 4: Patches Needing Approval (MANUAL status)
|
||||||
report.append("\n" + "=" * 70)
|
report.append("\n" + "=" * 70)
|
||||||
report.append("SECTION 4: DEVICES NEEDING MOST ATTENTION")
|
report.append("SECTION 4: PATCHES NEEDING APPROVAL (Manual Policy Review)")
|
||||||
|
report.append("=" * 70)
|
||||||
|
|
||||||
|
manual_patches = [d for d in device_details if d['status'] == 'MANUAL']
|
||||||
|
if manual_patches:
|
||||||
|
# Group by organization, then device
|
||||||
|
manual_by_org = defaultdict(lambda: defaultdict(list))
|
||||||
|
for patch in manual_patches:
|
||||||
|
manual_by_org[patch['organization']][patch['device_name']].append(patch)
|
||||||
|
|
||||||
|
report.append(f"\n Total patches needing approval: {len(manual_patches)}")
|
||||||
|
report.append(f" Across {len(manual_by_org)} organizations\n")
|
||||||
|
|
||||||
|
for org in sorted(manual_by_org.keys()):
|
||||||
|
devices = manual_by_org[org]
|
||||||
|
total_patches = sum(len(patches) for patches in devices.values())
|
||||||
|
report.append(f"\n {org} ({total_patches} patches, {len(devices)} devices)")
|
||||||
|
report.append(" " + "-" * 50)
|
||||||
|
for device_name in sorted(devices.keys()):
|
||||||
|
patches = devices[device_name]
|
||||||
|
report.append(f" {device_name}:")
|
||||||
|
for patch in patches:
|
||||||
|
impact_marker = "[CRIT]" if patch['impact'] == 'critical' else "[" + patch['impact'][:4].upper() + "]"
|
||||||
|
report.append(f" {impact_marker} {patch['title']}")
|
||||||
|
else:
|
||||||
|
report.append("\n No patches pending manual approval")
|
||||||
|
|
||||||
|
# Section 5: Devices needing attention (most patches)
|
||||||
|
report.append("\n" + "=" * 70)
|
||||||
|
report.append("SECTION 5: DEVICES NEEDING MOST ATTENTION")
|
||||||
report.append("=" * 70)
|
report.append("=" * 70)
|
||||||
|
|
||||||
device_patch_count = defaultdict(lambda: {'total': 0, 'critical': 0, 'name': '', 'org': ''})
|
device_patch_count = defaultdict(lambda: {'total': 0, 'critical': 0, 'name': '', 'org': ''})
|
||||||
@@ -309,7 +342,7 @@ class NinjaOneReporter:
|
|||||||
html_file = f"cve_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
html_file = f"cve_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
||||||
html_content = self.generate_html_report(
|
html_content = self.generate_html_report(
|
||||||
timestamp, sorted_categories, sorted_orgs, critical_by_title,
|
timestamp, sorted_categories, sorted_orgs, critical_by_title,
|
||||||
sorted_devices, os_patches, sw_patches, critical_items
|
sorted_devices, os_patches, sw_patches, critical_items, manual_by_org
|
||||||
)
|
)
|
||||||
with open(html_file, 'w') as f:
|
with open(html_file, 'w') as f:
|
||||||
f.write(html_content)
|
f.write(html_content)
|
||||||
@@ -318,7 +351,7 @@ class NinjaOneReporter:
|
|||||||
print(report_text)
|
print(report_text)
|
||||||
return 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):
|
def generate_html_report(self, timestamp, sorted_categories, sorted_orgs, critical_by_title, sorted_devices, os_patches, sw_patches, critical_items, manual_by_org=None):
|
||||||
"""Generate HTML report"""
|
"""Generate HTML report"""
|
||||||
html = f'''<!DOCTYPE html>
|
html = f'''<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -407,6 +440,10 @@ class NinjaOneReporter:
|
|||||||
<h3>Critical Items</h3>
|
<h3>Critical Items</h3>
|
||||||
<div class="number">{len(critical_items)}</div>
|
<div class="number">{len(critical_items)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card warning">
|
||||||
|
<h3>Pending Approval</h3>
|
||||||
|
<div class="number">{sum(len(patches) for devices in (manual_by_org or {}).values() for patches in devices.values())}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Patches by Category</h2>
|
<h2>Patches by Category</h2>
|
||||||
@@ -486,6 +523,42 @@ class NinjaOneReporter:
|
|||||||
html += '''
|
html += '''
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>Patches Needing Approval</h2>
|
||||||
|
<div class="section">
|
||||||
|
<p>These patches require manual policy approval before they can be deployed:</p>
|
||||||
|
'''
|
||||||
|
if manual_by_org:
|
||||||
|
total_manual = sum(
|
||||||
|
len(patches)
|
||||||
|
for devices in manual_by_org.values()
|
||||||
|
for patches in devices.values()
|
||||||
|
)
|
||||||
|
html += f'<p><strong>Total:</strong> {total_manual} patches across {len(manual_by_org)} organizations</p>'
|
||||||
|
html += '<table><thead><tr><th>Organization</th><th>Device</th><th>Patches Needing Approval</th></tr></thead><tbody>'
|
||||||
|
for org in sorted(manual_by_org.keys()):
|
||||||
|
devices = manual_by_org[org]
|
||||||
|
first_org = True
|
||||||
|
for device_name in sorted(devices.keys()):
|
||||||
|
patches = devices[device_name]
|
||||||
|
patch_list = '<br>'.join([
|
||||||
|
f'<span class="badge badge-{"critical" if p["impact"]=="critical" else "medium"}">{p["impact"][:4].upper()}</span> {p["title"]}'
|
||||||
|
for p in patches
|
||||||
|
])
|
||||||
|
org_cell = f'<td rowspan="{len(devices)}"><strong>{org}</strong></td>' if first_org else ''
|
||||||
|
first_org = False
|
||||||
|
html += f'''
|
||||||
|
<tr>
|
||||||
|
{org_cell}
|
||||||
|
<td>{device_name}</td>
|
||||||
|
<td>{patch_list}</td>
|
||||||
|
</tr>'''
|
||||||
|
html += '</tbody></table>'
|
||||||
|
else:
|
||||||
|
html += '<p style="color: #276749;">No patches pending manual approval.</p>'
|
||||||
|
|
||||||
|
html += '''
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Devices Needing Most Attention</h2>
|
<h2>Devices Needing Most Attention</h2>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<table>
|
<table>
|
||||||
@@ -557,6 +630,13 @@ class NinjaOneReporter:
|
|||||||
|
|
||||||
print(f"\nFound {len(critical_patches)} critical patches on {len(devices_to_patch)} devices")
|
print(f"\nFound {len(critical_patches)} critical patches on {len(devices_to_patch)} devices")
|
||||||
|
|
||||||
|
# Show status breakdown
|
||||||
|
from collections import Counter
|
||||||
|
statuses = Counter(p.get('status') for p in critical_patches)
|
||||||
|
print("\nPatch status breakdown:")
|
||||||
|
for status, count in statuses.most_common():
|
||||||
|
print(f" {status}: {count}")
|
||||||
|
|
||||||
if not devices_to_patch:
|
if not devices_to_patch:
|
||||||
print("No devices need patching.")
|
print("No devices need patching.")
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user