Add PatchReportExtractor.py
This commit is contained in:
506
PatchReportExtractor.py
Normal file
506
PatchReportExtractor.py
Normal file
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract structured action items from Nessus Plugin 66334 (Patch Report)
|
||||
This provides pre-packaged, actionable patch recommendations per host
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict
|
||||
import csv
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatchAction:
|
||||
"""Represents a patch action from Plugin 66334"""
|
||||
action: str
|
||||
system: str
|
||||
ip_address: str
|
||||
vulnerability_count: int
|
||||
software: str = ""
|
||||
version_needed: str = ""
|
||||
critical_count: int = 0
|
||||
high_count: int = 0
|
||||
medium_count: int = 0
|
||||
related_cves: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.related_cves is None:
|
||||
self.related_cves = []
|
||||
|
||||
|
||||
class PatchReportParser:
|
||||
"""Parses Plugin 66334 Patch Report data from .nessus files"""
|
||||
|
||||
def __init__(self):
|
||||
self.patch_actions: List[PatchAction] = []
|
||||
self.all_vulnerabilities: Dict = {} # Store all vulns for cross-reference
|
||||
self.host_vulnerabilities: Dict = {} # Vulns per host for matching
|
||||
|
||||
def parse_file(self, filepath: str):
|
||||
"""Parse a single .nessus file for Plugin 66334 data"""
|
||||
try:
|
||||
tree = ET.parse(filepath)
|
||||
root = tree.getroot()
|
||||
|
||||
for report_host in root.findall('.//ReportHost'):
|
||||
host_ip = report_host.get('name')
|
||||
host_properties = report_host.find('HostProperties')
|
||||
system_name = host_ip
|
||||
|
||||
if host_properties is not None:
|
||||
for tag in host_properties.findall('tag'):
|
||||
if tag.get('name') == 'host-fqdn':
|
||||
system_name = tag.text
|
||||
break
|
||||
elif tag.get('name') == 'netbios-name' and not system_name != host_ip:
|
||||
system_name = tag.text
|
||||
|
||||
# Store this host's vulnerabilities
|
||||
host_key = f"{system_name}_{host_ip}"
|
||||
self.host_vulnerabilities[host_key] = []
|
||||
|
||||
# Collect ALL vulnerabilities for this host (for cross-reference)
|
||||
for item in report_host.findall('ReportItem'):
|
||||
plugin_id = item.get('pluginID')
|
||||
plugin_name = item.get('pluginName', '')
|
||||
severity = int(item.get('severity', 0))
|
||||
|
||||
# Handle Plugin 66334 specially (before skipping informational)
|
||||
if plugin_id == '66334':
|
||||
self._parse_patch_report_output(item, system_name, host_ip)
|
||||
continue # Don't add it to host_vulnerabilities
|
||||
|
||||
# Skip informational
|
||||
if severity == 0:
|
||||
continue
|
||||
|
||||
# Collect CVEs
|
||||
cves = []
|
||||
for cve in item.findall('cve'):
|
||||
if cve.text:
|
||||
cves.append(cve.text)
|
||||
|
||||
# Store vulnerability info
|
||||
vuln_info = {
|
||||
'plugin_id': plugin_id,
|
||||
'plugin_name': plugin_name,
|
||||
'severity': severity,
|
||||
'cves': cves
|
||||
}
|
||||
self.host_vulnerabilities[host_key].append(vuln_info)
|
||||
|
||||
# Now cross-reference patch actions with vulnerabilities
|
||||
self._match_patch_actions_to_vulnerabilities()
|
||||
|
||||
print(f"✓ Parsed: {filepath}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error parsing {filepath}: {str(e)}")
|
||||
|
||||
def _parse_patch_report_output(self, report_item, system_name: str, host_ip: str):
|
||||
"""Extract structured actions from the patch report output"""
|
||||
plugin_output = report_item.find('plugin_output')
|
||||
if plugin_output is None or not plugin_output.text:
|
||||
return
|
||||
|
||||
output = plugin_output.text
|
||||
|
||||
# Pattern 1: Microsoft KB patches
|
||||
# Example with count: - KB5073724 (39 vulnerabilities)
|
||||
# Example without count: - KB5049613
|
||||
kb_pattern = r'-\s*(KB\d+)(?:\s*\((\d+)\s+vulnerabilit(?:y|ies)\))?'
|
||||
kb_matches = re.findall(kb_pattern, output, re.IGNORECASE)
|
||||
|
||||
if kb_matches:
|
||||
for kb, count in kb_matches:
|
||||
action = PatchAction(
|
||||
action=f"Install Microsoft patch {kb}",
|
||||
system=system_name,
|
||||
ip_address=host_ip,
|
||||
vulnerability_count=int(count) if count else 0,
|
||||
software="Microsoft Windows",
|
||||
version_needed=kb
|
||||
)
|
||||
self.patch_actions.append(action)
|
||||
|
||||
# Pattern 2: Software upgrades with structured format
|
||||
# Example:
|
||||
# [ 7-Zip < 25.01 (249179) ]
|
||||
# + Action to take : Upgrade to 7-Zip version 25.01 or later.
|
||||
# +Impact : Taking this action will resolve 5 different vulnerabilities (CVEs).
|
||||
|
||||
# Match the software/version line
|
||||
software_blocks = re.split(r'\n\s*\+\s*Action to take\s*:', output)
|
||||
|
||||
for i in range(1, len(software_blocks)):
|
||||
block = software_blocks[i]
|
||||
|
||||
# Extract action description
|
||||
action_match = re.search(r'^([^\n]+)', block)
|
||||
if not action_match:
|
||||
continue
|
||||
|
||||
action_text = action_match.group(1).strip()
|
||||
|
||||
# Extract impact/vulnerability count
|
||||
impact_match = re.search(r'resolve\s+(\d+)\s+different vulnerabilit(?:y|ies)', block, re.IGNORECASE)
|
||||
vuln_count = int(impact_match.group(1)) if impact_match else 0
|
||||
|
||||
# Try to extract software name and version from the previous block
|
||||
prev_block_start = max(0, i - 1)
|
||||
prev_text = software_blocks[prev_block_start][-200:] # Last 200 chars
|
||||
|
||||
software_name = ""
|
||||
version_needed = ""
|
||||
|
||||
# Pattern: [ Software < version (...) ]
|
||||
sw_match = re.search(r'\[\s*([^\<\[]+?)\s*<\s*([\d.]+)', prev_text)
|
||||
if sw_match:
|
||||
software_name = sw_match.group(1).strip()
|
||||
version_needed = sw_match.group(2).strip()
|
||||
|
||||
action = PatchAction(
|
||||
action=action_text,
|
||||
system=system_name,
|
||||
ip_address=host_ip,
|
||||
vulnerability_count=vuln_count,
|
||||
software=software_name,
|
||||
version_needed=version_needed
|
||||
)
|
||||
self.patch_actions.append(action)
|
||||
|
||||
def _match_patch_actions_to_vulnerabilities(self):
|
||||
"""Cross-reference patch actions with actual vulnerabilities to get severity counts"""
|
||||
for action in self.patch_actions:
|
||||
host_key = f"{action.system}_{action.ip_address}"
|
||||
|
||||
if host_key not in self.host_vulnerabilities:
|
||||
continue
|
||||
|
||||
host_vulns = self.host_vulnerabilities[host_key]
|
||||
|
||||
# Match vulnerabilities to this action based on:
|
||||
# 1. KB number in action matches KB in vulnerability
|
||||
# 2. Software name in action matches software in vulnerability plugin name
|
||||
# 3. CVEs mentioned in patch report
|
||||
|
||||
matched_vulns = []
|
||||
|
||||
for vuln in host_vulns:
|
||||
plugin_name = vuln['plugin_name'].lower()
|
||||
action_text = action.action.lower()
|
||||
software_name = action.software.lower()
|
||||
|
||||
# Match KB patches
|
||||
if 'kb' in action_text:
|
||||
kb_match = re.search(r'kb(\d+)', action_text)
|
||||
if kb_match:
|
||||
kb_num = kb_match.group(1)
|
||||
if f'kb{kb_num}' in plugin_name or kb_num in plugin_name:
|
||||
matched_vulns.append(vuln)
|
||||
continue
|
||||
|
||||
# Match software name
|
||||
if software_name and software_name in plugin_name:
|
||||
# Further validate by checking version if available
|
||||
if action.version_needed:
|
||||
# If version is mentioned, check if this vuln relates to older versions
|
||||
if '<' in plugin_name or 'unsupported' in plugin_name or 'outdated' in plugin_name:
|
||||
matched_vulns.append(vuln)
|
||||
else:
|
||||
matched_vulns.append(vuln)
|
||||
continue
|
||||
|
||||
# Match common software patterns
|
||||
software_keywords = {
|
||||
'7-zip': ['7-zip', '7zip'],
|
||||
'apache': ['apache'],
|
||||
'log4j': ['log4j'],
|
||||
'openssh': ['openssh', 'ssh'],
|
||||
'dell': ['dell'],
|
||||
'microsoft': ['microsoft', 'windows', r'ms\d{2}-'],
|
||||
'adobe': ['adobe'],
|
||||
'air': ['adobe air', 'air'],
|
||||
'curl': ['curl'],
|
||||
'edge': ['edge', 'chromium'],
|
||||
'oracle': ['oracle'],
|
||||
'java': ['java', 'openjdk', 'jre', 'jdk'],
|
||||
'rhel': ['rhel', 'red hat'],
|
||||
'networkmanager': ['networkmanager'],
|
||||
'bcc': ['bcc'],
|
||||
}
|
||||
|
||||
for key, patterns in software_keywords.items():
|
||||
if key in software_name or key in action_text:
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, plugin_name, re.IGNORECASE):
|
||||
matched_vulns.append(vuln)
|
||||
break
|
||||
|
||||
# Calculate severity counts from matched vulnerabilities
|
||||
critical = sum(1 for v in matched_vulns if v['severity'] == 4)
|
||||
high = sum(1 for v in matched_vulns if v['severity'] == 3)
|
||||
medium = sum(1 for v in matched_vulns if v['severity'] == 2)
|
||||
|
||||
# Collect CVEs
|
||||
cves = []
|
||||
for v in matched_vulns:
|
||||
cves.extend(v['cves'])
|
||||
|
||||
# Update action with severity info
|
||||
action.critical_count = critical
|
||||
action.high_count = high
|
||||
action.medium_count = medium
|
||||
action.related_cves = list(set(cves)) # Unique CVEs
|
||||
|
||||
def parse_multiple_files(self, filepaths: List[str]):
|
||||
"""Parse multiple .nessus files"""
|
||||
for filepath in filepaths:
|
||||
self.parse_file(filepath)
|
||||
|
||||
print(f"\nTotal patch actions extracted: {len(self.patch_actions)}")
|
||||
|
||||
def export_to_csv(self, output_file: str):
|
||||
"""Export patch actions to CSV"""
|
||||
if not self.patch_actions:
|
||||
print("No patch actions found.")
|
||||
return
|
||||
|
||||
with open(output_file, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
fieldnames = [
|
||||
'Action',
|
||||
'System',
|
||||
'IP Address',
|
||||
'CVE Reduction Count',
|
||||
'Critical Reduction',
|
||||
'High Reduction',
|
||||
'Medium Reduction',
|
||||
'Software',
|
||||
'Version Needed',
|
||||
'System Impact Risk',
|
||||
'Recommended IFC',
|
||||
'3rd Party Software',
|
||||
'Responsible Party',
|
||||
'Documentation',
|
||||
'Comments'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for action in self.patch_actions:
|
||||
writer.writerow({
|
||||
'Action': action.action,
|
||||
'System': action.system,
|
||||
'IP Address': action.ip_address,
|
||||
'CVE Reduction Count': len(action.related_cves),
|
||||
'Critical Reduction': action.critical_count,
|
||||
'High Reduction': action.high_count,
|
||||
'Medium Reduction': action.medium_count,
|
||||
'Software': action.software,
|
||||
'Version Needed': action.version_needed,
|
||||
'System Impact Risk': '',
|
||||
'Recommended IFC': '',
|
||||
'3rd Party Software': '',
|
||||
'Responsible Party': '',
|
||||
'Documentation': '',
|
||||
'Comments': ''
|
||||
})
|
||||
|
||||
print(f"\n✓ Patch actions exported to: {output_file}")
|
||||
print(f" Total action items: {len(self.patch_actions)}")
|
||||
|
||||
def export_summary_csv(self, output_file: str):
|
||||
"""Export summary of patch actions aggregated by action type"""
|
||||
if not self.patch_actions:
|
||||
print("No patch actions found.")
|
||||
return
|
||||
|
||||
# Aggregate by action
|
||||
action_summary = defaultdict(lambda: {
|
||||
'systems': set(),
|
||||
'total_vulns': 0,
|
||||
'total_cves': set(),
|
||||
'critical': 0,
|
||||
'high': 0,
|
||||
'medium': 0
|
||||
})
|
||||
|
||||
for action in self.patch_actions:
|
||||
key = action.action
|
||||
action_summary[key]['systems'].add(action.system)
|
||||
action_summary[key]['total_vulns'] += action.vulnerability_count
|
||||
action_summary[key]['total_cves'].update(action.related_cves)
|
||||
action_summary[key]['critical'] += action.critical_count
|
||||
action_summary[key]['high'] += action.high_count
|
||||
action_summary[key]['medium'] += action.medium_count
|
||||
|
||||
# Create summary list
|
||||
summary_list = []
|
||||
total_critical = sum(a.critical_count for a in self.patch_actions)
|
||||
total_high = sum(a.high_count for a in self.patch_actions)
|
||||
total_cves = len(set(cve for a in self.patch_actions for cve in a.related_cves))
|
||||
|
||||
for action_text, data in action_summary.items():
|
||||
summary_list.append({
|
||||
'action': action_text,
|
||||
'system_count': len(data['systems']),
|
||||
'cve_count': len(data['total_cves']),
|
||||
'cve_percent': (len(data['total_cves']) / total_cves * 100) if total_cves > 0 else 0,
|
||||
'critical': data['critical'],
|
||||
'critical_percent': (data['critical'] / total_critical * 100) if total_critical > 0 else 0,
|
||||
'high': data['high'],
|
||||
'high_percent': (data['high'] / total_high * 100) if total_high > 0 else 0
|
||||
})
|
||||
|
||||
# Sort by critical then high
|
||||
summary_list.sort(key=lambda x: (x['critical'], x['high'], x['cve_count']), reverse=True)
|
||||
|
||||
# Write summary CSV
|
||||
with open(output_file, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
fieldnames = [
|
||||
'Action',
|
||||
'System Count',
|
||||
'CVE Reduction',
|
||||
'% of Total CVE Reduction',
|
||||
'Critical Reduction',
|
||||
'% of Total Critical Reduction',
|
||||
'High Reduction',
|
||||
'% of Total High Reduction'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for item in summary_list:
|
||||
writer.writerow({
|
||||
'Action': item['action'],
|
||||
'System Count': item['system_count'],
|
||||
'CVE Reduction': item['cve_count'],
|
||||
'% of Total CVE Reduction': f"{item['cve_percent']:.1f}%",
|
||||
'Critical Reduction': item['critical'],
|
||||
'% of Total Critical Reduction': f"{item['critical_percent']:.1f}%",
|
||||
'High Reduction': item['high'],
|
||||
'% of Total High Reduction': f"{item['high_percent']:.1f}%"
|
||||
})
|
||||
|
||||
print(f"\n✓ Summary exported to: {output_file}")
|
||||
print(f" Total unique actions: {len(summary_list)}")
|
||||
print(f" Total CVEs: {total_cves}")
|
||||
print(f" Total Critical vulnerabilities: {total_critical}")
|
||||
print(f" Total High vulnerabilities: {total_high}")
|
||||
|
||||
def print_summary(self):
|
||||
"""Print a summary of patch actions"""
|
||||
if not self.patch_actions:
|
||||
print("No patch actions found.")
|
||||
return
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("PATCH REPORT ACTION PLAN SUMMARY (Plugin 66334)")
|
||||
print("="*80)
|
||||
|
||||
total_cves = len(set(cve for a in self.patch_actions for cve in a.related_cves))
|
||||
total_critical = sum(a.critical_count for a in self.patch_actions)
|
||||
total_high = sum(a.high_count for a in self.patch_actions)
|
||||
unique_systems = len(set(a.system for a in self.patch_actions))
|
||||
|
||||
print(f"\nTotal Actions: {len(self.patch_actions)}")
|
||||
print(f"Unique Systems: {unique_systems}")
|
||||
print(f"Total CVEs Addressed: {total_cves}")
|
||||
print(f"Total Critical Vulnerabilities: {total_critical}")
|
||||
print(f"Total High Vulnerabilities: {total_high}")
|
||||
|
||||
print(f"\n{'Priority':<10} {'Action':<45} {'System':<15} {'Crit':<6} {'High':<6} {'CVEs':<6}")
|
||||
print("-"*95)
|
||||
|
||||
# Sort by critical then high
|
||||
sorted_actions = sorted(
|
||||
self.patch_actions,
|
||||
key=lambda x: (x.critical_count, x.high_count, len(x.related_cves)),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for i, action in enumerate(sorted_actions[:15], 1):
|
||||
action_short = action.action[:42] + "..." if len(action.action) > 45 else action.action
|
||||
system_short = action.system[:12] + "..." if len(action.system) > 15 else action.system
|
||||
cve_count = len(action.related_cves)
|
||||
print(f"{i:<10} {action_short:<45} {system_short:<15} {action.critical_count:<6} {action.high_count:<6} {cve_count:<6}")
|
||||
|
||||
if len(sorted_actions) > 15:
|
||||
print(f"\n... and {len(sorted_actions) - 15} more actions")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Extract structured patch actions from Nessus Plugin 66334 (Patch Report)',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s scan1.nessus scan2.nessus -o patch_actions.csv
|
||||
%(prog)s *.nessus --summary -o patches.csv
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'nessus_files',
|
||||
nargs='+',
|
||||
help='One or more .nessus files to process'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-o', '--output',
|
||||
default='patch_report_actions.csv',
|
||||
help='Output CSV file (default: patch_report_actions.csv)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--summary',
|
||||
action='store_true',
|
||||
help='Also generate a summary CSV file'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate input files
|
||||
valid_files = []
|
||||
for filepath in args.nessus_files:
|
||||
if Path(filepath).exists():
|
||||
valid_files.append(filepath)
|
||||
else:
|
||||
print(f"Warning: File not found: {filepath}")
|
||||
|
||||
if not valid_files:
|
||||
print("Error: No valid .nessus files provided")
|
||||
return 1
|
||||
|
||||
print(f"\nExtracting Patch Report data from {len(valid_files)} .nessus file(s)...\n")
|
||||
|
||||
# Parse files
|
||||
parser_obj = PatchReportParser()
|
||||
parser_obj.parse_multiple_files(valid_files)
|
||||
|
||||
# Display summary
|
||||
parser_obj.print_summary()
|
||||
|
||||
# Export detailed actions
|
||||
parser_obj.export_to_csv(args.output)
|
||||
|
||||
# Optionally export summary
|
||||
if args.summary:
|
||||
output_path = Path(args.output)
|
||||
summary_filename = output_path.stem + '_summary' + output_path.suffix
|
||||
summary_path = output_path.parent / summary_filename
|
||||
parser_obj.export_summary_csv(str(summary_path))
|
||||
|
||||
print("\n✓ Complete!")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user