# Copyright (c) 2010-2025 Jacob Hammack.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


require 'set'

ActiveRecord::Migration.verbose = false

module Risu
	module Parsers
		module Nessus

			# NessusSaxListener
			class NessusSaxListener
				include LibXML::XML::SaxParser::Callbacks

				attr_accessor :new_tags

				# An array of valid reference element names
				VALID_REFERENCES = Set.new(%w[
					cpe bid see_also xref cve iava msft
					osvdb cert edb-id rhsa secunia suse dsa
					owasp cwe iavb iavt cisco-sa ics-alert
					cisco-bug-id cisco-sr cert-vu vmsa apple-sa
					icsa cert-cc msvr usn hp glsa freebsd tra
				])

				# An array of valid host properties
				VALID_HOST_PROPERTIES = Set.new(%w[
					HOST_END mac-address HOST_START operating-system host-ip host-fqdn netbios-name
					local-checks-proto smb-login-used ssh-auth-meth ssh-login-used pci-dss-compliance
					pci-dss-compliance: system-type bios-uuid pcidss:compliance:failed pcidss:compliance:passed
					pcidss:deprecated_ssl pcidss:expired_ssl_certificate pcidss:high_risk_flaw pcidss:medium_risk_flaw
					pcidss:reachable_db pcidss:www:xss pcidss:directory_browsing pcidss:known_credentials
					pcidss:compromised_host:worm pcidss:obsolete_operating_system pcidss:dns_zone_transfer
					pcidss:unprotected_mssql_db pcidss:obsolete_software pcidss:www:sql_injection pcidss:backup_files
					traceroute-hop-0 traceroute-hop-1 traceroute-hop-2 operating-system-unsupported patch-summary-total-cves
					pcidss:insecure_http_methods LastUnauthenticatedResults LastAuthenticatedResults cpe-0 cpe-1
					cpe-2 cpe-3 Credentialed_Scan policy-used UnsupportedProduct:microsoft:windows_xp::sp2
					UnsupportedProduct:microsoft:windows_xp UnsupportedProduct:microsoft:windows_2000 UnsupportedProduct
					mcafee-epo-guid
				])

				# An array of all valid elements expected during parsing
				VALID_ELEMENTS = VALID_REFERENCES \
					+ Set.new(%w[ReportItem plugin_version risk_factor
					description cvss_base_score solution item plugin_output tag synopsis plugin_modification_date
					FamilyName FamilyItem Status vuln_publication_date ReportHost HostProperties preferenceName
					preferenceValues preferenceType fullName pluginId pluginName selectedValue selectedValue
					name value preference plugin_publication_date cvss_vector patch_publication_date
					NessusClientData_v2 Policy PluginName ServerPreferences policyComments policyName PluginItem
					Report Family Preferences PluginsPreferences FamilySelection IndividualPluginSelection PluginId
					pci-dss-compliance exploitability_ease cvss_temporal_vector exploit_framework_core cvss_temporal_score
					exploit_available metasploit_name exploit_framework_canvas canvas_package exploit_framework_metasploit
					plugin_type exploithub_sku exploit_framework_exploithub stig_severity plugin_name fname always_run
					cm:compliance-info cm:compliance-actual-value cm:compliance-check-id cm:compliance-policy-value
					cm:compliance-audit-file cm:compliance-check-name cm:compliance-result cm:compliance-output policyOwner
					visibility script_version attachment policy_comments d2_elliot_name exploit_framework_d2_elliot
					exploited_by_malware compliance cm:compliance-reference cm:compliance-see-also cm:compliance-solution
					agent potential_vulnerability in_the_news exploited_by_nessus unsupported_by_vendor default_account
				])

				# TODO: documentation. These are never used in the class
				VALID_HOST_PROPERTIES_REGEX = [
					"patch-summary-cve-num", "patch-summary-cves", "patch-summary-txt", "cpe-\d+", "KB\d+"
				]

				# Map of host properties to symbols for use with ActiveRecord
				#   interfaces
				#
				# These are the more commonly used host properties,
				#   mapping them here to store in the host table
				HOST_PROPERTIES_MAPPING = {
					"HOST_END" => :end,
					"mac-address" => :mac,
					"HOST_START" => :start,
					"operating-system" => :os,
					"host-ip" => :ip,
					"host-fqdn" => :fqdn,
					"netbios-name" => :netbios
				}

				# Used to map element names to private methods so
				#   that the methods can be called when the parser
				#   encounters the opening of an element.
				#
				# {"ElementName" => :start_method_to_be_called}
				#
				# @example call #start_policy for a "Policy" element
				#   element = "Policy"
				#   send(DYNAMIC_START_METHOD_NAMES[element], element, attributes)
				#
				# @param element [String] the element name starting to be parsed
				# @param attributes [Array<Hash{Sring=>String}>] the array of
				#   key value pairs for the element that is starting to be parsed
				DYNAMIC_START_METHOD_NAMES = {
					"Policy" => :start_policy,
					"preference" => :start_preference,
					"item" => :start_item,
					"FamilyItem" => :start_family_item,
					"PluginItem" => :start_plugin_item,
					"Report" => :start_report,
					"ReportHost" => :start_report_host,
					"tag" => :start_tag,
					"ReportItem" => :start_report_item,
					"attachment" => :start_attachment
				}

				# @see DYNAMIC_START_MEHTOD_NAMES
				#
				# @note only one argument for the methods
				#
				# @example call #end_policy_name for a "policyName" element
				#   element = "policyName"
				#   send(DYNAMIC_END_METHOD_NAMES[element], element)
				#
				# @param element [String] the name of the element ending
				DYNAMIC_END_METHOD_NAMES = {
					"policyName" => :end_policy_name,
					"policyComments" => :end_policy_comments,
					"policy_comments" => :end_policy_comments,
					"policyOwner" => :end_policy_owner,
					"visibility" => :end_visibility,
					"preference" => :end_preference,
					"item" => :end_item,
					"FamilyItem" => :end_family_item,
					"PluginItem" => :end_plugin_item,
					"tag" => :end_tag,
					"ReportItem" => :end_report_item,
					"attachment" => :end_attachment
				}

				private_constant :DYNAMIC_END_METHOD_NAMES, :DYNAMIC_START_METHOD_NAMES,
					:HOST_PROPERTIES_MAPPING, :VALID_HOST_PROPERTIES_REGEX, :VALID_HOST_PROPERTIES,
					:VALID_ELEMENTS, :VALID_REFERENCES

				# vals tracks state for elements encountered during parsing
				def initialize
					@vals = Hash.new
					@new_tags = Array.new
				end

				# Callback for when the start of a XML element is reached
				#
				# @param element XML element
				# @param attributes Attributes for the XML element
				def on_start_element(element, attributes)
					@tag = element
					@vals[@tag] = ""

					if !VALID_ELEMENTS.include?(element)
						@new_tags << "New XML element detected: #{element}. Please report this at #{Risu::GITHUB}/issues/new or via email to #{Risu::EMAIL}"
					end

					if DYNAMIC_START_METHOD_NAMES.key?(element)
						# Dynamic dispatch to private instance "policyComments"methods in the const hash
						#   DYNAMIC_START_METHOD_NAMES where {"element" => :start_element}
						send(DYNAMIC_START_METHOD_NAMES[element], element, attributes)
					end
				end

				# Called when the inner text of a element is reached
				#
				# @param text
				def on_characters(text)
					if @vals[@tag] == nil then
						@vals[@tag] = text
					else
						@vals[@tag] << text
					end
				end

				# Called when the end of the XML element is reached
				#
				# @param element
				def on_end_element(element)
					@tag = nil

					if DYNAMIC_END_METHOD_NAMES.key?(element)
						# Dynamic dispatch to private instance methods in the const hash
						#   DYNAMIC_END_METHOD_NAMES where {"element" => :end_element}
						send(DYNAMIC_END_METHOD_NAMES[element], element)
					elsif VALID_REFERENCES.include?(element)
						end_valid_reference(element)
					end
				end

				private

				# Dynamic dispatch start element methods
				def start_policy(_element, _attributes)
					@policy = Risu::Models::Policy.create
				end

				def start_preference(_element, _attributes)
					@sp = @policy.server_preferences.create
				end

				def start_item(_element, _attributes)
					@item = @policy.plugins_preferences.create
				end

				def start_family_item(_element, _attributes)
					@family = @policy.family_selections.create
				end

				def start_plugin_item(_element, _attributes)
					@plugin_selection = @policy.individual_plugin_selections.create
				end

				def start_report(_element, attributes)
					@report = @policy.reports.create(:name => attributes["name"])
				end

				def start_report_host(_element, attributes)
					@rh = @report.hosts.create(:name => attributes["name"])
				end

				def start_tag(_, attributes)
					@attr = nil
					@hp = @rh.host_properties.create

					if attributes["name"] =~ /[M|m][S|s]\d{2,}-\d{2,}/
						@attr = attributes["name"]
					#Ugly as fuck. Really this needs to be rewritten. Fuck.
					elsif attributes['name'] =~ /
									(?:patch-summary-cve-num)
									| (?:patch-summary-cves)
									| (?:patch-summary-txt)
									| (?:cpe-\d+)
									| (?:KB\d+)
								/x
						@attr = attributes["name"]
					elsif VALID_HOST_PROPERTIES.include?(attributes["name"])
						@attr = attributes["name"]
					end

					# implicit nil check?
					if attributes["name"] !~ /(netstat-(?:established|listen)-(?:tcp|udp)\d+-\d+)/ \
							&& attributes["name"] !~ /traceroute-hop-\d+/ \
							&& @attr.nil?
						@new_tags << "New HostProperties attribute: #{attributes["name"]}. Please report this at #{Risu::GITHUB}/issues/new or via email to #{Risu::EMAIL}\n"
					end
				end

				def start_report_item(_element, attributes)
					@vals = Hash.new # have to clear this out or everything has the same references

					if attributes["pluginID"] == "0"
						@plugin = Risu::Models::Plugin.find_or_create_by(:id => 1)
					else
						@plugin = Risu::Models::Plugin.find_or_create_by(:id => attributes["pluginID"]) do |plugin|
							plugin.plugin_name = attributes["pluginName"]
							plugin.family_name = attributes["pluginFamily"]
						end
					end

					@ri = @rh.items.create(:port => attributes["port"],
									:svc_name => attributes["svc_name"],
									:protocol => attributes["protocol"],
									:severity => attributes["severity"],
									:plugin_id => @plugin.id
								)

					@plugin.save
				end

				def start_attachment(_element, attributes)
					@attachment = @ri.attachments.create(:name => attributes['name'], :ttype => attributes['type'])
				end

				# Dynamic dispatch end element methods
				def end_policy_name(_)
					@policy.update(:name => @vals["policyName"])
				end

				def end_policy_comments(element)
					return unless element == "policyComments" || element == "policy_comments"
					@policy.update(:comments => @vals[element])
				end

				def end_policy_owner(_)
					@policy.update(:owner => @vals["policyOwner"])
				end

				def end_visibility(_)
					@policy.update(:visibility => @vals["visibility"])
				end

				def end_preference(_)
					@sp.update(:name => @vals["name"], :value => @vals["value"])
				end

					# This takes a really long time, there is about 34,000 pluginIDs in this
					# field and it takes about 36 minutes to parse just this info =\
					# lets pre-populate the plugins table with the known plugin_id's
					#
					# if @vals["name"] == "plugin_set"
					# 	 @all_plugins = @vals["value"].split(";")
					#
					# 	 @all_plugins.each { |p|
					# 			@plug = Plugin.find_or_create_by_id(p)
					# 			@plug.save
					# 	 }
					# end
				def end_item(_)
					@item.update(:plugin_name => @vals["pluginName"],
						:plugin_id => @vals["pluginId"], :fullname => @vals["fullName"],
						:preference_name => @vals["preferenceName"],
						:preference_type => @vals["preferenceType"],
						:preference_values => @vals["preferenceValues"],
						:selected_values => @vals["selectedValue"])
				end

				def end_family_item(_)
					@family.update( :family_name => @vals["FamilyName"],
						:status => @vals["Status"])
				end

				def end_plugin_item(_)
					@plugin_selection.update(:plugin_id => @vals["PluginId"],
						:plugin_name => @vals["PluginName"],
						:family => @vals["Family"], :status => @vals["Status"])
				end

				def end_tag(_)
					return if @attr.nil?

					if @attr =~ /[M|m][S|s]\d{2}-\d{2,}/
						@patch = @rh.patches.create(:name => @attr, :value => @vals['tag'])
					else
						if HOST_PROPERTIES_MAPPING.key?(@attr)
							@rh.update(HOST_PROPERTIES_MAPPING[@attr] => @vals["tag"].gsub("\n", ","))
						end

						@hp.update(:name => @attr, :value => @vals['tag'])
					end
				end

				#We cannot handle the references in the same block as the rest of the ReportItem tag because
				#there tends to be more than of the different types of reference per ReportItem, this causes issue for a sax
				#parser. To solve this we do the references before the final plugin data, Valid references must be added
				#the VALID_REFERENCE set at the top to be parsed.
				def end_valid_reference(element)
					@ref = @plugin.references.create(:reference_name => element, :value => @vals["#{element}"])
				end

				def end_report_item(_)
					@ri.update(:plugin_output => @vals["plugin_output"],
						:plugin_name => @vals["plugin_name"],
						:cm_compliance_info => @vals["cm:compliance-info"],
						:cm_compliance_actual_value => @vals["cm:compliance-actual-value"],
						:cm_compliance_check_id => @vals["cm:compliance-check-id"],
						:cm_compliance_policy_value => @vals["cm:compliance-policy-value"],
						:cm_compliance_audit_file => @vals["cm:compliance-audit-file"],
						:cm_compliance_check_name => @vals["cm:compliance-check-name"],
						:cm_compliance_result => @vals["cm:compliance-result"],
						:cm_compliance_output => @vals["cm:compliance-output"],
						:cm_compliance_reference => @vals["cm:compliance-reference"],
						:cm_compliance_see_also => @vals["cm:compliance-see-also" ],
						:cm_compliance_solution => @vals["cm:compliance-solution"],
						:rollup_finding => false
					)

					@plugin.update(:solution => @vals["solution"],
						:risk_factor => @vals["risk_factor"],
						:description => @vals["description"],
						:plugin_publication_date => @vals["plugin_publication_date"],
						:plugin_modification_date => @vals["plugin_modification_date"],
						:synopsis => @vals["synopsis"],
						:plugin_type => @vals["plugin_type"],
						:cvss_vector => @vals["cvss_vector"],
						:cvss_base_score => @vals["cvss_base_score"].to_f,
						:vuln_publication_date => @vals["vuln_publication_date"],
						:plugin_version => @vals["plugin_version"],
						:cvss_temporal_score => @vals["cvss_temporal_score"],
						:cvss_temporal_vector => @vals["cvss_temporal_vector"],
						:exploitability_ease => @vals["exploitability_ease"],
						:exploit_framework_core => @vals["exploit_framework_core"],
						:exploit_available => @vals["exploit_available"] == "true",
						:exploit_framework_metasploit => @vals["exploit_framework_metasploit"],
						:metasploit_name => @vals["metasploit_name"],
						:exploit_framework_canvas => @vals["exploit_framework_canvas"],
						:canvas_package => @vals["canvas_package"],
						:exploit_framework_exploithub => @vals["exploit_framework_exploithub"],
						:exploithub_sku => @vals["exploithub_sku"],
						:stig_severity => @vals["stig_severity"],
						:fname => @vals["fname"],
						:always_run => @vals["always_run"],
						:script_version => @vals["script_version"],
						:exploited_by_malware => @vals["exploited_by_malware"],
						:compliance => @vals["compliance"],
						:agent => @vals["agent"],
						:in_the_news => @vals["in_the_news"]

					)
				end

				def end_attachment(_)
					@attachment.update(:ahash => @vals['attachment'])
				end
			end
		end
	end
end
