From d14432ce596a658d4aaf93ad0ff66a78445f8305 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 18 Dec 2025 16:34:22 -0500 Subject: [PATCH] Add patches needing approval section to reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 ++ report.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 89fa436..d40e5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ ENV/ # Reports cve_report_*.txt +cve_report_*.html +cve_details_*.csv *.log # IDE diff --git a/report.py b/report.py index 958648b..400bec4 100644 --- a/report.py +++ b/report.py @@ -135,11 +135,15 @@ class NinjaOneReporter: device_details = [] # Process OS patches + # OS patches use 'name' and 'severity' fields 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() + # 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' by_category[category]['total'] += 1 @@ -154,7 +158,7 @@ class NinjaOneReporter: by_org[org_name]['critical'] += 1 critical_items.append({ 'type': 'OS', - 'title': patch.get('title', 'Unknown'), + 'title': title, 'device_id': device_id, 'device_name': device.get('name', 'Unknown'), 'org': org_name, @@ -164,7 +168,7 @@ class NinjaOneReporter: device_details.append({ 'type': 'OS', 'category': category, - 'title': patch.get('title', 'Unknown'), + 'title': title, 'impact': impact, 'status': patch.get('status', 'Unknown'), 'device_id': device_id, @@ -267,9 +271,38 @@ class NinjaOneReporter: else: 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("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) 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_content = self.generate_html_report( 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: f.write(html_content) @@ -318,7 +351,7 @@ class NinjaOneReporter: 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): + 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""" html = f''' @@ -407,6 +440,10 @@ class NinjaOneReporter:

Critical Items

{len(critical_items)}
+
+

Pending Approval

+
{sum(len(patches) for devices in (manual_by_org or {}).values() for patches in devices.values())}
+

Patches by Category

@@ -486,6 +523,42 @@ class NinjaOneReporter: html += ''' +

Patches Needing Approval

+
+

These patches require manual policy approval before they can be deployed:

+''' + if manual_by_org: + total_manual = sum( + len(patches) + for devices in manual_by_org.values() + for patches in devices.values() + ) + html += f'

Total: {total_manual} patches across {len(manual_by_org)} organizations

' + html += '' + 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 = '
'.join([ + f'{p["impact"][:4].upper()} {p["title"]}' + for p in patches + ]) + org_cell = f'' if first_org else '' + first_org = False + html += f''' + + {org_cell} + + + ''' + html += '
OrganizationDevicePatches Needing Approval
{org}
{device_name}{patch_list}
' + else: + html += '

No patches pending manual approval.

' + + html += ''' +
+

Devices Needing Most Attention

@@ -557,6 +630,13 @@ class NinjaOneReporter: 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: print("No devices need patching.") return