From adbd419622d7ec78755e33b21bbe679e1c148540 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 18 Dec 2025 13:03:00 -0500 Subject: [PATCH] Add patch remediation feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --remediate: Dry run showing critical patches to approve - --approve: Actually approve all critical patches via API - Patches deploy during device maintenance windows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- report.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/report.py b/report.py index a530627..e8dd091 100644 --- a/report.py +++ b/report.py @@ -523,6 +523,102 @@ class NinjaOneReporter: return html + def approve_critical_patches(self, dry_run=True): + """Approve all critical patches across all devices""" + self.authenticate() + self.load_organizations() + self.load_devices() + + print("\nLoading software patches...") + sw_result = self.api_get('/queries/software-patches') + sw_patches = sw_result.get('results', []) if isinstance(sw_result, dict) else sw_result + + # Filter to critical patches that are not already approved + critical_patches = [ + p for p in sw_patches + if p.get('impact', '').lower() == 'critical' + and p.get('status', '').upper() != 'APPROVED' + ] + + print(f"\nFound {len(critical_patches)} critical patches to approve") + + if not critical_patches: + print("No critical patches need approval.") + return + + # Group by title for summary + by_title = {} + for patch in critical_patches: + title = patch.get('title', 'Unknown') + if title not in by_title: + by_title[title] = [] + by_title[title].append(patch) + + print("\nCritical patches to approve:") + print("-" * 60) + for title, patches in sorted(by_title.items(), key=lambda x: len(x[1]), reverse=True): + device_names = [self.devices.get(p['deviceId'], {}).get('name', f"Device-{p['deviceId']}") for p in patches] + print(f" {title}: {len(patches)} devices") + for name in device_names[:5]: + print(f" - {name}") + if len(device_names) > 5: + print(f" ... and {len(device_names) - 5} more") + + if dry_run: + print("\n" + "=" * 60) + print("DRY RUN - No changes made") + print("Run with --approve to actually approve these patches") + print("=" * 60) + return + + # Actually approve patches + print("\n" + "=" * 60) + print("APPROVING PATCHES...") + print("=" * 60) + + approved = 0 + failed = 0 + + for patch in critical_patches: + device_id = patch.get('deviceId') + patch_id = patch.get('id') + title = patch.get('title', 'Unknown') + device_name = self.devices.get(device_id, {}).get('name', f"Device-{device_id}") + + try: + # Approve the patch + headers = {'Authorization': f'Bearer {self.access_token}', 'Content-Type': 'application/json'} + url = f"{self.config['base_url']}/api/v2/device/{device_id}/software-patch/{patch_id}/approve" + response = requests.post(url, headers=headers) + + if response.status_code in [200, 201, 204]: + print(f" Approved: {title} on {device_name}") + approved += 1 + else: + print(f" Failed: {title} on {device_name} - {response.status_code}: {response.text[:100]}") + failed += 1 + except Exception as e: + print(f" Error: {title} on {device_name} - {str(e)}") + failed += 1 + + print("\n" + "=" * 60) + print(f"COMPLETE: {approved} approved, {failed} failed") + print("=" * 60) + print("\nPatches will be installed during each device's maintenance window.") + print("Check NinjaOne dashboard for deployment status.") + + if __name__ == '__main__': + import sys + reporter = NinjaOneReporter() - reporter.generate_report() + + if '--approve' in sys.argv: + # Actually approve critical patches + reporter.approve_critical_patches(dry_run=False) + elif '--remediate' in sys.argv: + # Dry run - show what would be approved + reporter.approve_critical_patches(dry_run=True) + else: + # Default: generate report + reporter.generate_report()