# encoding: utf-8 require "logstash/filters/base" require "logstash/namespace" require "logstash/filters/xml/patch_rexml" # XML filter. Takes a field that contains XML and expands it into # an actual datastructure. class LogStash::Filters::Xml < LogStash::Filters::Base config_name "xml" # Config for xml to hash is: # [source,ruby] # source => source_field # # For example, if you have the whole XML document in your `message` field: # [source,ruby] # filter { # xml { # source => "message" # } # } # # The above would parse the XML from the `message` field. config :source, :validate => :string, :required => true # Define target for placing the data # # For example if you want the data to be put in the `doc` field: # [source,ruby] # filter { # xml { # target => "doc" # } # } # # XML in the value of the source field will be expanded into a # datastructure in the `target` field. # Note: if the `target` field already exists, it will be overridden. # Required if `store_xml` is true (which is the default). config :target, :validate => :string # xpath will additionally select string values (non-strings will be # converted to strings with Ruby's `to_s` function) from parsed XML # (using each source field defined using the method above) and place # those values in the destination fields. Configuration: # [source,ruby] # xpath => [ "xpath-syntax", "destination-field" ] # # Values returned by XPath parsing from `xpath-syntax` will be put in the # destination field. Multiple values returned will be pushed onto the # destination field as an array. As such, multiple matches across # multiple source fields will produce duplicate entries in the field. # # More on XPath: https://www.w3schools.com/xml/xml_xpath.asp # # The XPath functions are particularly powerful: # https://www.w3schools.com/xml/xsl_functions.asp # config :xpath, :validate => :hash, :default => {} # Supported XML parsing options are 'strict', 'no_error' and 'no_warning'. # - strict mode turns on strict parsing rules (non-compliant xml will fail) # - no_error and no_warning can be used to suppress errors/warnings config :parse_options, :validate => :string # NOTE: technically we support more but we purposefully do not document those. # e.g. setting "strict|recover" will not turn on strict as they're conflicting # By default the filter will store the whole parsed XML in the destination # field as described above. Setting this to false will prevent that. config :store_xml, :validate => :boolean, :default => true # By default the filter will force single elements to be arrays. Setting this to # false will prevent storing single elements in arrays. config :force_array, :validate => :boolean, :default => true # By default the filter will expand attributes differently from content inside # of tags. This option allows you to force text content and attributes to always # parse to a hash value. config :force_content, :validate => :boolean, :default => false # By default only namespaces declarations on the root element are considered. # This allows to configure all namespace declarations to parse the XML document. # # Example: # # [source,ruby] # filter { # xml { # namespaces => { # "xsl" => "http://www.w3.org/1999/XSL/Transform" # "xhtml" => http://www.w3.org/1999/xhtml" # } # } # } # config :namespaces, :validate => :hash, :default => {} # Remove all namespaces from all nodes in the document. # Of course, if the document had nodes with the same names but different namespaces, they will now be ambiguous. config :remove_namespaces, :validate => :boolean, :default => false # By default, output nothing if the element is empty. # If set to `false`, empty element will result in an empty hash object. config :suppress_empty, :validate => :boolean, :default => true XMLPARSEFAILURE_TAG = "_xmlparsefailure" def register require "nokogiri" require "xmlsimple" if @store_xml && (!@target || @target.empty?) raise LogStash::ConfigurationError, I18n.t( "logstash.runner.configuration.invalid_plugin_register", :plugin => "filter", :type => "xml", :error => "When the 'store_xml' configuration option is true, 'target' must also be set" ) end xml_parse_options # validates parse_options => ... end def filter(event) matched = false @logger.debug? && @logger.debug("Running xml filter", :event => event) value = event.get(@source) return unless value if value.is_a?(Array) if value.length != 1 event.tag(XMLPARSEFAILURE_TAG) @logger.warn("XML filter expects single item array", :source => @source, :value => value) return end value = value.first end unless value.is_a?(String) event.tag(XMLPARSEFAILURE_TAG) @logger.warn("XML filter expects a string but received a #{value.class}", :source => @source, :value => value) return end # Do nothing with an empty string. return if value.strip.empty? if @xpath begin doc = Nokogiri::XML::Document.parse(value, nil, value.encoding.to_s, xml_parse_options) rescue => e event.tag(XMLPARSEFAILURE_TAG) @logger.warn("Error parsing xml", :source => @source, :value => value, :exception => e, :backtrace => e.backtrace) return else doc.errors.any? && @logger.debug? && @logger.debug("Parsed xml with #{doc.errors.size} errors") end doc.remove_namespaces! if @remove_namespaces @xpath.each do |xpath_src, xpath_dest| nodeset = @namespaces.empty? ? doc.xpath(xpath_src) : doc.xpath(xpath_src, @namespaces) # If asking xpath for a String, like "name(/*)", we get back a # String instead of a NodeSet. We normalize that here. normalized_nodeset = nodeset.kind_of?(Nokogiri::XML::NodeSet) ? nodeset : [nodeset] # Initialize empty resultset data = [] normalized_nodeset.each do |value| # some XPath functions return empty arrays as string next if value.is_a?(Array) && value.length == 0 if value matched = true data << value.to_s end end # set the destination attribute, if it's an array with a bigger size than one, leave as is. otherwise make it a string. added force_array param to provide same functionality as writing it in an xml target if data.size == 1 && !@force_array event.set(xpath_dest, data[0]) else event.set(xpath_dest, data) unless data.nil? || data.empty? end end end if @store_xml begin xml_options = {"ForceArray" => @force_array, "ForceContent" => @force_content} xml_options["SuppressEmpty"] = true if @suppress_empty event.set(@target, XmlSimple.xml_in(value, xml_options)) matched = true rescue => e event.tag(XMLPARSEFAILURE_TAG) @logger.warn("Error parsing xml with XmlSimple", :source => @source, :value => value, :exception => e, :backtrace => e.backtrace) return end end filter_matched(event) if matched @logger.debug? && @logger.debug("Event after xml filter", :event => event) rescue => e event.tag(XMLPARSEFAILURE_TAG) log_payload = { :exception => e.message, :source => @source } log_payload[:value] = value unless value.nil? log_payload[:backtrace] = e.backtrace if @logger.debug? @logger.warn("XML Parse Error", log_payload) end private def xml_parse_options return Nokogiri::XML::ParseOptions::DEFAULT_XML unless @parse_options # (RECOVER | NONET) @xml_parse_options ||= begin parse_options = @parse_options.split(/,|\|/).map do |opt| name = opt.strip.tr('_', '').upcase if name.empty? nil else begin Nokogiri::XML::ParseOptions.const_get(name) rescue NameError raise LogStash::ConfigurationError, "unsupported parse option: #{opt.inspect}" end end end parse_options.compact.inject(0, :|) # e.g. NOERROR | NOWARNING end end end