From b8fe088d4b1f6b2ce0797a114be757fc0bb34d2a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 18 Dec 2025 13:08:49 -0500 Subject: [PATCH] Fix patch remediation to use correct API endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use POST /v2/device/{id}/patch/software/apply - Triggers patch installation per device (not per patch) - Shows offline device failures clearly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- report.py | 102 +++++++++++++++++++++++++++--------------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/report.py b/report.py index e8dd091..b04c9cb 100644 --- a/report.py +++ b/report.py @@ -523,89 +523,89 @@ class NinjaOneReporter: return html - def approve_critical_patches(self, dry_run=True): - """Approve all critical patches across all devices""" + def apply_patches(self, dry_run=True, patch_type='software'): + """Trigger patch apply on devices with critical patches""" 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 + print(f"\nLoading {patch_type} patches...") + if patch_type == 'software': + result = self.api_get('/queries/software-patches') + else: + result = self.api_get('/queries/os-patches') - # Filter to critical patches that are not already approved + patches = result.get('results', []) if isinstance(result, dict) else result + + # Filter to critical patches critical_patches = [ - p for p in sw_patches + p for p in patches if p.get('impact', '').lower() == 'critical' - and p.get('status', '').upper() != 'APPROVED' ] - print(f"\nFound {len(critical_patches)} critical patches to approve") + # Get unique devices with critical patches + devices_to_patch = {} + for patch in critical_patches: + device_id = patch.get('deviceId') + if device_id not in devices_to_patch: + devices_to_patch[device_id] = { + 'name': self.devices.get(device_id, {}).get('name', f"Device-{device_id}"), + 'org': self.devices.get(device_id, {}).get('org_name', 'Unknown'), + 'patches': [] + } + devices_to_patch[device_id]['patches'].append(patch.get('title', 'Unknown')) - if not critical_patches: - print("No critical patches need approval.") + print(f"\nFound {len(critical_patches)} critical patches on {len(devices_to_patch)} devices") + + if not devices_to_patch: + print("No devices need patching.") 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("\nDevices to patch:") 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") + for device_id, info in sorted(devices_to_patch.items(), key=lambda x: len(x[1]['patches']), reverse=True): + print(f" {info['name']} ({info['org']}): {len(info['patches'])} critical patches") + for title in info['patches'][:3]: + print(f" - {title}") + if len(info['patches']) > 3: + print(f" ... and {len(info['patches']) - 3} more") if dry_run: print("\n" + "=" * 60) print("DRY RUN - No changes made") - print("Run with --approve to actually approve these patches") + print("Run with --apply to trigger patch installation") print("=" * 60) return - # Actually approve patches + # Trigger patch apply on each device print("\n" + "=" * 60) - print("APPROVING PATCHES...") + print("TRIGGERING PATCH APPLY...") print("=" * 60) - approved = 0 + success = 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}") - + for device_id, info in devices_to_patch.items(): 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" + url = f"{self.config['base_url']}/api/v2/device/{device_id}/patch/{patch_type}/apply" response = requests.post(url, headers=headers) if response.status_code in [200, 201, 204]: - print(f" Approved: {title} on {device_name}") - approved += 1 + print(f" Triggered: {info['name']} ({len(info['patches'])} patches)") + success += 1 else: - print(f" Failed: {title} on {device_name} - {response.status_code}: {response.text[:100]}") + print(f" Failed: {info['name']} - {response.status_code}: {response.text[:100]}") failed += 1 except Exception as e: - print(f" Error: {title} on {device_name} - {str(e)}") + print(f" Error: {info['name']} - {str(e)}") failed += 1 print("\n" + "=" * 60) - print(f"COMPLETE: {approved} approved, {failed} failed") + print(f"COMPLETE: {success} devices triggered, {failed} failed") print("=" * 60) - print("\nPatches will be installed during each device's maintenance window.") - print("Check NinjaOne dashboard for deployment status.") + print("\nPatch installation has been triggered on these devices.") + print("Check NinjaOne dashboard for progress.") if __name__ == '__main__': @@ -613,12 +613,12 @@ if __name__ == '__main__': reporter = NinjaOneReporter() - if '--approve' in sys.argv: - # Actually approve critical patches - reporter.approve_critical_patches(dry_run=False) + if '--apply' in sys.argv: + # Actually trigger patch apply on devices + reporter.apply_patches(dry_run=False) elif '--remediate' in sys.argv: - # Dry run - show what would be approved - reporter.approve_critical_patches(dry_run=True) + # Dry run - show what would be patched + reporter.apply_patches(dry_run=True) else: # Default: generate report reporter.generate_report()