Fix patch remediation to use correct API endpoint
- 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 <noreply@anthropic.com>
This commit is contained in:
102
report.py
102
report.py
@@ -523,89 +523,89 @@ class NinjaOneReporter:
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def approve_critical_patches(self, dry_run=True):
|
def apply_patches(self, dry_run=True, patch_type='software'):
|
||||||
"""Approve all critical patches across all devices"""
|
"""Trigger patch apply on devices with critical patches"""
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
self.load_organizations()
|
self.load_organizations()
|
||||||
self.load_devices()
|
self.load_devices()
|
||||||
|
|
||||||
print("\nLoading software patches...")
|
print(f"\nLoading {patch_type} patches...")
|
||||||
sw_result = self.api_get('/queries/software-patches')
|
if patch_type == 'software':
|
||||||
sw_patches = sw_result.get('results', []) if isinstance(sw_result, dict) else sw_result
|
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 = [
|
critical_patches = [
|
||||||
p for p in sw_patches
|
p for p in patches
|
||||||
if p.get('impact', '').lower() == 'critical'
|
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(f"\nFound {len(critical_patches)} critical patches on {len(devices_to_patch)} devices")
|
||||||
print("No critical patches need approval.")
|
|
||||||
|
if not devices_to_patch:
|
||||||
|
print("No devices need patching.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Group by title for summary
|
print("\nDevices to patch:")
|
||||||
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)
|
print("-" * 60)
|
||||||
for title, patches in sorted(by_title.items(), key=lambda x: len(x[1]), reverse=True):
|
for device_id, info in sorted(devices_to_patch.items(), key=lambda x: len(x[1]['patches']), reverse=True):
|
||||||
device_names = [self.devices.get(p['deviceId'], {}).get('name', f"Device-{p['deviceId']}") for p in patches]
|
print(f" {info['name']} ({info['org']}): {len(info['patches'])} critical patches")
|
||||||
print(f" {title}: {len(patches)} devices")
|
for title in info['patches'][:3]:
|
||||||
for name in device_names[:5]:
|
print(f" - {title}")
|
||||||
print(f" - {name}")
|
if len(info['patches']) > 3:
|
||||||
if len(device_names) > 5:
|
print(f" ... and {len(info['patches']) - 3} more")
|
||||||
print(f" ... and {len(device_names) - 5} more")
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("DRY RUN - No changes made")
|
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)
|
print("=" * 60)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Actually approve patches
|
# Trigger patch apply on each device
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("APPROVING PATCHES...")
|
print("TRIGGERING PATCH APPLY...")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
approved = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
for patch in critical_patches:
|
for device_id, info in devices_to_patch.items():
|
||||||
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:
|
try:
|
||||||
# Approve the patch
|
|
||||||
headers = {'Authorization': f'Bearer {self.access_token}', 'Content-Type': 'application/json'}
|
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)
|
response = requests.post(url, headers=headers)
|
||||||
|
|
||||||
if response.status_code in [200, 201, 204]:
|
if response.status_code in [200, 201, 204]:
|
||||||
print(f" Approved: {title} on {device_name}")
|
print(f" Triggered: {info['name']} ({len(info['patches'])} patches)")
|
||||||
approved += 1
|
success += 1
|
||||||
else:
|
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
|
failed += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Error: {title} on {device_name} - {str(e)}")
|
print(f" Error: {info['name']} - {str(e)}")
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print(f"COMPLETE: {approved} approved, {failed} failed")
|
print(f"COMPLETE: {success} devices triggered, {failed} failed")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("\nPatches will be installed during each device's maintenance window.")
|
print("\nPatch installation has been triggered on these devices.")
|
||||||
print("Check NinjaOne dashboard for deployment status.")
|
print("Check NinjaOne dashboard for progress.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -613,12 +613,12 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
reporter = NinjaOneReporter()
|
reporter = NinjaOneReporter()
|
||||||
|
|
||||||
if '--approve' in sys.argv:
|
if '--apply' in sys.argv:
|
||||||
# Actually approve critical patches
|
# Actually trigger patch apply on devices
|
||||||
reporter.approve_critical_patches(dry_run=False)
|
reporter.apply_patches(dry_run=False)
|
||||||
elif '--remediate' in sys.argv:
|
elif '--remediate' in sys.argv:
|
||||||
# Dry run - show what would be approved
|
# Dry run - show what would be patched
|
||||||
reporter.approve_critical_patches(dry_run=True)
|
reporter.apply_patches(dry_run=True)
|
||||||
else:
|
else:
|
||||||
# Default: generate report
|
# Default: generate report
|
||||||
reporter.generate_report()
|
reporter.generate_report()
|
||||||
|
|||||||
Reference in New Issue
Block a user