From f84272124b77d579bbdd8ab1bb545d77f6981397 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 4 Sep 2024 16:12:11 -0400 Subject: [PATCH 1/6] separate full & simple formatters into importers --- lib/dradis/plugins/nexpose.rb | 3 +- lib/dradis/plugins/nexpose/engine.rb | 12 ++ lib/dradis/plugins/nexpose/formats/full.rb | 163 -------------- lib/dradis/plugins/nexpose/formats/simple.rb | 76 ------- lib/dradis/plugins/nexpose/full/importer.rb | 204 ++++++++++++++++++ lib/dradis/plugins/nexpose/importer.rb | 38 ---- lib/dradis/plugins/nexpose/simple/importer.rb | 117 ++++++++++ 7 files changed, 335 insertions(+), 278 deletions(-) delete mode 100644 lib/dradis/plugins/nexpose/formats/full.rb delete mode 100644 lib/dradis/plugins/nexpose/formats/simple.rb create mode 100644 lib/dradis/plugins/nexpose/full/importer.rb delete mode 100644 lib/dradis/plugins/nexpose/importer.rb create mode 100644 lib/dradis/plugins/nexpose/simple/importer.rb diff --git a/lib/dradis/plugins/nexpose.rb b/lib/dradis/plugins/nexpose.rb index d062ceb..18bfc2e 100644 --- a/lib/dradis/plugins/nexpose.rb +++ b/lib/dradis/plugins/nexpose.rb @@ -7,6 +7,7 @@ module Nexpose require 'dradis/plugins/nexpose/engine' require 'dradis/plugins/nexpose/field_processor' -require 'dradis/plugins/nexpose/importer' +require 'dradis/plugins/nexpose/full/importer' require 'dradis/plugins/nexpose/mapping' +require 'dradis/plugins/nexpose/simple/importer' require 'dradis/plugins/nexpose/version' diff --git a/lib/dradis/plugins/nexpose/engine.rb b/lib/dradis/plugins/nexpose/engine.rb index e0e32a5..f6dc5ca 100644 --- a/lib/dradis/plugins/nexpose/engine.rb +++ b/lib/dradis/plugins/nexpose/engine.rb @@ -5,5 +5,17 @@ class Engine < ::Rails::Engine include ::Dradis::Plugins::Base description 'Processes Nexpose XML format' provides :upload + + # Because this plugin provides two export modules, we have to overwrite + # the default .uploaders() method. + # + # See: + # Dradis::Plugins::Upload::Base in dradis-plugins + def self.uploaders + [ + Dradis::Plugins::Nexpose::Full, + Dradis::Plugins::Nexpose::Simple + ] + end end end diff --git a/lib/dradis/plugins/nexpose/formats/full.rb b/lib/dradis/plugins/nexpose/formats/full.rb deleted file mode 100644 index 956f7c3..0000000 --- a/lib/dradis/plugins/nexpose/formats/full.rb +++ /dev/null @@ -1,163 +0,0 @@ -module Dradis::Plugins::Nexpose::Formats - # This module knows how to parse Nexpose Ful XML format. - module Full - private - - def process_full(doc) - note_text = nil - - @vuln_list = [] - evidence = Hash.new { |h, k| h[k] = {} } - - # First, extract scans - scan_node = content_service.create_node(label: 'Nexpose Scan Summary') - logger.info { "\tProcessing scan summary" } - - doc.xpath('//scans/scan').each do |xml_scan| - note_text = mapping_service.apply_mapping(source: 'full_scan', data: xml_scan) - content_service.create_note(node: scan_node, text: note_text) - end - - # Second, we parse the nodes - doc.xpath('//nodes/node').each do |xml_node| - nexpose_node = Nexpose::Node.new(xml_node) - - host_node = content_service.create_node(label: nexpose_node.address, type: :host) - logger.info { "\tProcessing host: #{nexpose_node.address}" } - - # add the summary note for this host - note_text = mapping_service.apply_mapping(source: 'full_node', data: nexpose_node) - content_service.create_note(node: host_node, text: note_text) - - if host_node.respond_to?(:properties) - logger.info { "\tAdding host properties to #{nexpose_node.address}" } - host_node.set_property(:ip, nexpose_node.address) - host_node.set_property(:hostname, nexpose_node.names) - host_node.set_property(:os, nexpose_node.fingerprints) - host_node.set_property(:risk_score, nexpose_node.risk_score) - host_node.save - end - - # inject this node's address into any vulnerabilities identified - # - # TODO: There is room for improvement here, we could have a hash that - # linked vulns with test/service and host to create proper content for - # Evidence. - nexpose_node.tests.each do |node_test| - test_id = node_test[:id].to_s.downcase - - # We can't use the straightforward version below because Nexpose uses - # mixed-case some times (!) - # xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[@id='#{node_test[:id]}']").first - # See: - # http://stackoverflow.com/questions/1625446/problem-with-upper-case-and-lower-case-xpath-functions-in-selenium-ide/1625859#1625859 - xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='#{test_id}']").first - xml_vuln.add_child('') unless xml_vuln.last_element_child.name == 'hosts' - - if xml_vuln.xpath("./hosts/host[text()='#{nexpose_node.address}']").empty? - xml_vuln.last_element_child.add_child("#{nexpose_node.address}") - end - - evidence[test_id][nexpose_node.address] ||= [] - evidence[test_id][nexpose_node.address] << node_test - end - - nexpose_node.endpoints.each do |endpoint| - # endpoint_node = content_service.create_node(label: endpoint.label, parent: host_node) - logger.info { "\t\tEndpoint: #{endpoint.label}" } - - if host_node.respond_to?(:properties) - logger.info { "\t\tAdding to Services table" } - host_node.set_service( - port: endpoint.port.to_i, - protocol: endpoint.protocol, - state: endpoint.status, - name: endpoint.services.map(&:name).join(', '), - source: :nexpose, - # reason: port.reason, - # product: port.try('service').try('product'), - # version: port.try('service').try('version') - ) - end - - endpoint.services.each do |service| - - # add the summary note for this service - note_text = mapping_service.apply_mapping(source: 'full_service', data: service) - # content_service.create_note(node: endpoint_node, text: note_text) - content_service.create_note(node: host_node, text: note_text) - - # inject this node's address into any vulnerabilities identified - service.tests.each do |service_test| - test_id = service_test[:id].to_s.downcase - - # For some reason Nexpose fails to include the 'http-iis-0011' vulnerability definition - next if test_id == 'http-iis-0011' - - # We can't use the straightforward version below because Nexpose uses - # mixed-case some times (!) - # xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[@id='#{service_test[:id]}']").first - # See: - # http://stackoverflow.com/questions/1625446/problem-with-upper-case-and-lower-case-xpath-functions-in-selenium-ide/1625859#1625859 - # - xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='#{test_id}']").first - xml_vuln.add_child('') unless xml_vuln.last_element_child.name == 'hosts' - - if xml_vuln.xpath("./hosts/host[text()='#{nexpose_node.address}']").empty? - xml_vuln.last_element_child.add_child("#{nexpose_node.address}") - end - - evidence[test_id][nexpose_node.address] ||= [] - evidence[test_id][nexpose_node.address] << service_test - end - end - end - - # add note under this node for each vulnerable ./node/test/ - host_node.save - end - - # Third, parse vulnerability definitions - logger.info { "\tProcessing issue definitions:" } - - doc.xpath('//VulnerabilityDefinitions/vulnerability').each do |xml_vulnerability| - id = xml_vulnerability['id'].downcase - # if @vuln_list.include?(id) - issue_text = mapping_service.apply_mapping( - source: 'full_vulnerability', - data: xml_vulnerability - ) - - # retrieve hosts affected by this issue (injected in step 2) - # - # There is no need for the below as Issues are linked to hosts via the - # corresponding Evidence instance - # - # note_text << "\n\n#[host]#\n" - # note_text << xml_vulnerability.xpath('./hosts/host').collect(&:text).join("\n") - # note_text << "\n\n" - - # 3.1 create the Issue - issue = content_service.create_issue(text: issue_text, id: id) - logger.info { "\tIssue: #{issue.fields ? issue.fields['Title'] : id}" } - - # 3.2 associate with the nodes via Evidence. - # TODO: there is room for improvement here by providing proper Evidence content - xml_vulnerability.xpath('./hosts/host').map(&:text).each do |host_name| - # if the node exists, this just returns it - host_node = content_service.create_node(label: host_name, type: :host) - - evidence[id][host_name].each do |evidence| - evidence_content = mapping_service.apply_mapping( - source: 'full_evidence', - data: evidence - ) - content_service.create_evidence(content: evidence_content, issue: issue, node: host_node) - end - end - - # end - end - end # /parse_nexpose_full_xml - end -end diff --git a/lib/dradis/plugins/nexpose/formats/simple.rb b/lib/dradis/plugins/nexpose/formats/simple.rb deleted file mode 100644 index 31788f4..0000000 --- a/lib/dradis/plugins/nexpose/formats/simple.rb +++ /dev/null @@ -1,76 +0,0 @@ -module Dradis::Plugins::Nexpose::Formats - - # This module knows how to parse Nexpose Simple XML format. - module Simple - private - - def process_simple(doc) - hosts = process_nexpose_simple_xml(doc) - notes_simple(hosts) - end - - def notes_simple(hosts) - return if hosts.nil? - - hosts.each do |host| - host_node = content_service.create_node(label: host['address'], type: :host) - content_service.create_note node: host_node, text: "Host Description : #{host['description']} \nScanner Fingerprint certainty : #{host['fingerprint']}" - - generic_findings_node = content_service.create_node(label: 'Generic Findings', parent: host_node) - host['generic_vulns'].each do |id, finding| - content_service.create_note node: generic_findings_node, text: "Finding ID : #{id} \n \n Finding Refs :\n-------\n #{finding}" - end - - port_text = nil - host['ports'].each do |port_label, findings| - port_node = content_service.create_node(label: port_label, parent: host_node) - - findings.each do |id, finding| - port_text = mapping_service.apply_mapping(source: 'simple_port', data: {id: id, finding: finding}) - port_text << "\n#[host]#\n#{host['address']}\n\n" - content_service.create_note node: port_node, text: port_text - end - end - end - end - - def process_nexpose_simple_xml(doc) - results = doc.search('device') - hosts = Array.new - results.each do |host| - current_host = Hash.new - current_host['address'] = host['address'] - current_host['fingerprint'] = host.search('fingerprint')[0].nil? ? "N/A" : host.search('fingerprint')[0]['certainty'] - current_host['description'] = host.search('description')[0].nil? ? "N/A" : host.search('description')[0].text - #So there's two sets of vulns in a NeXpose simple XML report for each host - #Theres some generic ones at the top of the report - #And some service specific ones further down the report. - #So we need to get the generic ones before moving on - current_host['generic_vulns'] = Hash.new - host.xpath('vulnerabilities/vulnerability').each do |vuln| - current_host['generic_vulns'][vuln['id']] = '' - vuln.xpath('id').each do |id| - current_host['generic_vulns'][vuln['id']] << id['type'] + " : " + id.text + "\n" - end - end - - current_host['ports'] = Hash.new - host.xpath('services/service').each do |service| - protocol = service['protocol'] - portid = service['port'] - port_label = protocol + '-' + portid - current_host['ports'][port_label] = Hash.new - service.xpath('vulnerabilities/vulnerability').each do |vuln| - current_host['ports'][port_label][vuln['id']] = '' - vuln.xpath('id').each do |id| - current_host['ports'][port_label][vuln['id']] << id['type'] + " : " + id.text + "\n" - end - end - end - - hosts << current_host - end - return hosts - end - end -end diff --git a/lib/dradis/plugins/nexpose/full/importer.rb b/lib/dradis/plugins/nexpose/full/importer.rb new file mode 100644 index 0000000..fc91028 --- /dev/null +++ b/lib/dradis/plugins/nexpose/full/importer.rb @@ -0,0 +1,204 @@ +module Dradis::Plugins::Nexpose + module Full + def self.meta + package = Dradis::Plugins::Nexpose + { + name: package::Engine::plugin_name, + description: 'Upload Full NeXpose output file (.xml)', + version: package.version + } + end + + class Importer < Dradis::Plugins::Upload::Importer + + def self.templates + { evidence: 'full_evidence', issue: 'full_vulnerability' } + end + + def initialize(args = {}) + args[:plugin] = Dradis::Plugins::Nexpose + super(args) + end + + # The framework will call this function if the user selects this plugin from + # the dropdown list and uploads a file. + # @returns true if the operation was successful, false otherwise + def import(params = {}) + file_content = File.read(params[:file]) + + logger.info { 'Parsing NeXpose-Full XML output file...' } + doc = Nokogiri::XML(file_content) + logger.info { 'Done.' } + + unless doc.root.name == 'NexposeReport' + error = "The document doesn't seem to be in NeXpose-Full XML format. Ensure you uploaded a NeXpose-Full XML report." + logger.fatal{ error } + content_service.create_note text: error + return false + end + + process_full(doc) + + logger.info { 'NeXpose-Full format successfully imported' } + true + end + + private + + def process_full(doc) + note_text = nil + + @vuln_list = [] + evidence = Hash.new { |h, k| h[k] = {} } + + # First, extract scans + scan_node = content_service.create_node(label: 'Nexpose Scan Summary') + logger.info { "\tProcessing scan summary" } + + doc.xpath('//scans/scan').each do |xml_scan| + note_text = mapping_service.apply_mapping(source: 'full_scan', data: xml_scan) + content_service.create_note(node: scan_node, text: note_text) + end + + # Second, we parse the nodes + doc.xpath('//nodes/node').each do |xml_node| + nexpose_node = Nexpose::Node.new(xml_node) + + host_node = content_service.create_node(label: nexpose_node.address, type: :host) + logger.info { "\tProcessing host: #{nexpose_node.address}" } + + # add the summary note for this host + note_text = mapping_service.apply_mapping(source: 'full_node', data: nexpose_node) + content_service.create_note(node: host_node, text: note_text) + + if host_node.respond_to?(:properties) + logger.info { "\tAdding host properties to #{nexpose_node.address}" } + host_node.set_property(:ip, nexpose_node.address) + host_node.set_property(:hostname, nexpose_node.names) + host_node.set_property(:os, nexpose_node.fingerprints) + host_node.set_property(:risk_score, nexpose_node.risk_score) + host_node.save + end + + # inject this node's address into any vulnerabilities identified + # + # TODO: There is room for improvement here, we could have a hash that + # linked vulns with test/service and host to create proper content for + # Evidence. + nexpose_node.tests.each do |node_test| + test_id = node_test[:id].to_s.downcase + + # We can't use the straightforward version below because Nexpose uses + # mixed-case some times (!) + # xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[@id='#{node_test[:id]}']").first + # See: + # http://stackoverflow.com/questions/1625446/problem-with-upper-case-and-lower-case-xpath-functions-in-selenium-ide/1625859#1625859 + xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='#{test_id}']").first + xml_vuln.add_child('') unless xml_vuln.last_element_child.name == 'hosts' + + if xml_vuln.xpath("./hosts/host[text()='#{nexpose_node.address}']").empty? + xml_vuln.last_element_child.add_child("#{nexpose_node.address}") + end + + evidence[test_id][nexpose_node.address] ||= [] + evidence[test_id][nexpose_node.address] << node_test + end + + nexpose_node.endpoints.each do |endpoint| + # endpoint_node = content_service.create_node(label: endpoint.label, parent: host_node) + logger.info { "\t\tEndpoint: #{endpoint.label}" } + + if host_node.respond_to?(:properties) + logger.info { "\t\tAdding to Services table" } + host_node.set_service( + port: endpoint.port.to_i, + protocol: endpoint.protocol, + state: endpoint.status, + name: endpoint.services.map(&:name).join(', '), + source: :nexpose, + # reason: port.reason, + # product: port.try('service').try('product'), + # version: port.try('service').try('version') + ) + end + + endpoint.services.each do |service| + + # add the summary note for this service + note_text = mapping_service.apply_mapping(source: 'full_service', data: service) + # content_service.create_note(node: endpoint_node, text: note_text) + content_service.create_note(node: host_node, text: note_text) + + # inject this node's address into any vulnerabilities identified + service.tests.each do |service_test| + test_id = service_test[:id].to_s.downcase + + # For some reason Nexpose fails to include the 'http-iis-0011' vulnerability definition + next if test_id == 'http-iis-0011' + + # We can't use the straightforward version below because Nexpose uses + # mixed-case some times (!) + # xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[@id='#{service_test[:id]}']").first + # See: + # http://stackoverflow.com/questions/1625446/problem-with-upper-case-and-lower-case-xpath-functions-in-selenium-ide/1625859#1625859 + # + xml_vuln = doc.xpath("//VulnerabilityDefinitions/vulnerability[translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='#{test_id}']").first + xml_vuln.add_child('') unless xml_vuln.last_element_child.name == 'hosts' + + if xml_vuln.xpath("./hosts/host[text()='#{nexpose_node.address}']").empty? + xml_vuln.last_element_child.add_child("#{nexpose_node.address}") + end + + evidence[test_id][nexpose_node.address] ||= [] + evidence[test_id][nexpose_node.address] << service_test + end + end + end + + # add note under this node for each vulnerable ./node/test/ + host_node.save + end + + # Third, parse vulnerability definitions + logger.info { "\tProcessing issue definitions:" } + + doc.xpath('//VulnerabilityDefinitions/vulnerability').each do |xml_vulnerability| + id = xml_vulnerability['id'].downcase + # if @vuln_list.include?(id) + issue_text = mapping_service.apply_mapping( + source: 'full_vulnerability', + data: xml_vulnerability + ) + + # retrieve hosts affected by this issue (injected in step 2) + # + # There is no need for the below as Issues are linked to hosts via the + # corresponding Evidence instance + # + # note_text << "\n\n#[host]#\n" + # note_text << xml_vulnerability.xpath('./hosts/host').collect(&:text).join("\n") + # note_text << "\n\n" + + # 3.1 create the Issue + issue = content_service.create_issue(text: issue_text, id: id) + logger.info { "\tIssue: #{issue.fields ? issue.fields['Title'] : id}" } + + # 3.2 associate with the nodes via Evidence. + # TODO: there is room for improvement here by providing proper Evidence content + xml_vulnerability.xpath('./hosts/host').map(&:text).each do |host_name| + # if the node exists, this just returns it + host_node = content_service.create_node(label: host_name, type: :host) + + evidence[id][host_name].each do |evidence| + evidence_content = mapping_service.apply_mapping( + source: 'full_evidence', + data: evidence + ) + content_service.create_evidence(content: evidence_content, issue: issue, node: host_node) + end + end + end + end + end + end +end diff --git a/lib/dradis/plugins/nexpose/importer.rb b/lib/dradis/plugins/nexpose/importer.rb deleted file mode 100644 index 0cfb431..0000000 --- a/lib/dradis/plugins/nexpose/importer.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'dradis/plugins/nexpose/formats/full' -require 'dradis/plugins/nexpose/formats/simple' - -module Dradis::Plugins::Nexpose - class Importer < Dradis::Plugins::Upload::Importer - - include Formats::Full - include Formats::Simple - - def self.templates - { evidence: 'full_evidence', issue: 'full_vulnerability' } - end - - # The framework will call this function if the user selects this plugin from - # the dropdown list and uploads a file. - # @returns true if the operation was successful, false otherwise - def import(params={}) - file_content = File.read( params[:file] ) - - logger.info { 'Parsing NeXpose output file...' } - doc = Nokogiri::XML(file_content) - logger.info { 'Done.' } - - if doc.root.name == 'NeXposeSimpleXML' - logger.info { 'NeXpose-Simple format detected' } - process_simple(doc) - elsif doc.root.name == 'NexposeReport' - logger.info { 'NeXpose-Full format detected' } - process_full(doc) - else - error = "The document doesn't seem to be in either NeXpose-Simple or NeXpose-Full XML format. Ensure you uploaded a Nexpose XML report." - logger.fatal{ error } - content_service.create_note text: error - return false - end - end - end -end diff --git a/lib/dradis/plugins/nexpose/simple/importer.rb b/lib/dradis/plugins/nexpose/simple/importer.rb new file mode 100644 index 0000000..9be26c7 --- /dev/null +++ b/lib/dradis/plugins/nexpose/simple/importer.rb @@ -0,0 +1,117 @@ +module Dradis::Plugins::Nexpose + module Simple + def self.meta + package = Dradis::Plugins::Nexpose + { + name: package::Engine::plugin_name, + description: 'Upload Simple NeXpose output file (.xml)', + version: package.version + } + end + + class Importer < Dradis::Plugins::Upload::Importer + def self.templates + {} + end + + def initialize(args = {}) + args[:plugin] = Dradis::Plugins::Nexpose + super(args) + end + + # The framework will call this function if the user selects this plugin from + # the dropdown list and uploads a file. + # @returns true if the operation was successful, false otherwise + def import(params = {}) + file_content = File.read( params[:file] ) + + logger.info { 'Parsing NeXpose output file...' } + doc = Nokogiri::XML(file_content) + logger.info { 'Done.' } + + unless doc.root.name == 'NeXposeSimpleXML' + error = "The document doesn't seem to be in either NeXpose-Simple or NeXpose-Full XML format. Ensure you uploaded a Nexpose XML report." + logger.fatal{ error } + content_service.create_note text: error + return false + end + + process_simple(doc) + + logger.info { 'NeXpose-Simple format uploaded successfully' } + true + end + + private + + def process_simple(doc) + hosts = process_nexpose_simple_xml(doc) + notes_simple(hosts) + end + + def notes_simple(hosts) + return if hosts.nil? + + hosts.each do |host| + host_node = content_service.create_node(label: host['address'], type: :host) + content_service.create_note node: host_node, text: "Host Description : #{host['description']} \nScanner Fingerprint certainty : #{host['fingerprint']}" + + generic_findings_node = content_service.create_node(label: 'Generic Findings', parent: host_node) + host['generic_vulns'].each do |id, finding| + content_service.create_note node: generic_findings_node, text: "Finding ID : #{id} \n \n Finding Refs :\n-------\n #{finding}" + end + + port_text = nil + host['ports'].each do |port_label, findings| + port_node = content_service.create_node(label: port_label, parent: host_node) + + findings.each do |id, finding| + port_text = mapping_service.apply_mapping(source: 'simple_port', data: {id: id, finding: finding}) + port_text << "\n#[host]#\n#{host['address']}\n\n" + content_service.create_note node: port_node, text: port_text + end + end + end + end + + def process_nexpose_simple_xml(doc) + results = doc.search('device') + hosts = Array.new + results.each do |host| + current_host = Hash.new + current_host['address'] = host['address'] + current_host['fingerprint'] = host.search('fingerprint')[0].nil? ? "N/A" : host.search('fingerprint')[0]['certainty'] + current_host['description'] = host.search('description')[0].nil? ? "N/A" : host.search('description')[0].text + #So there's two sets of vulns in a NeXpose simple XML report for each host + #Theres some generic ones at the top of the report + #And some service specific ones further down the report. + #So we need to get the generic ones before moving on + current_host['generic_vulns'] = Hash.new + host.xpath('vulnerabilities/vulnerability').each do |vuln| + current_host['generic_vulns'][vuln['id']] = '' + vuln.xpath('id').each do |id| + current_host['generic_vulns'][vuln['id']] << id['type'] + " : " + id.text + "\n" + end + end + + current_host['ports'] = Hash.new + host.xpath('services/service').each do |service| + protocol = service['protocol'] + portid = service['port'] + port_label = protocol + '-' + portid + current_host['ports'][port_label] = Hash.new + service.xpath('vulnerabilities/vulnerability').each do |vuln| + current_host['ports'][port_label][vuln['id']] = '' + vuln.xpath('id').each do |id| + current_host['ports'][port_label][vuln['id']] << id['type'] + " : " + id.text + "\n" + end + end + end + + hosts << current_host + end + return hosts + end + end + end +end From c92e3a1d91ca7166ecd70550d0c870fc33608eb4 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 4 Sep 2024 16:12:45 -0400 Subject: [PATCH 2/6] update thorfile and specs to work with multiple importers --- lib/dradis/plugins/nexpose/mapping.rb | 3 +- lib/tasks/thorfile.rb | 25 ++- spec/nexpose/full/importer_spec.rb | 108 +++++++++++++ spec/nexpose/simple/importer_spec.rb | 58 +++++++ spec/nexpose_upload_spec.rb | 221 -------------------------- spec/support/spec_macros.rb | 26 +++ 6 files changed, 213 insertions(+), 228 deletions(-) create mode 100644 spec/nexpose/full/importer_spec.rb create mode 100644 spec/nexpose/simple/importer_spec.rb delete mode 100644 spec/nexpose_upload_spec.rb create mode 100644 spec/support/spec_macros.rb diff --git a/lib/dradis/plugins/nexpose/mapping.rb b/lib/dradis/plugins/nexpose/mapping.rb index cfef18e..8de9704 100644 --- a/lib/dradis/plugins/nexpose/mapping.rb +++ b/lib/dradis/plugins/nexpose/mapping.rb @@ -11,7 +11,8 @@ module Mapping 'Hostname' => '{{ nexpose[node.site_name] }}', 'Details' => "Status: {{ nexpose[node.status] }}\nDevice id: {{ nexpose[node.device_id] }}\nHW address: {{ nexpose[node.hardware_address] }}", 'Names' => '{{ nexpose[node.names] }}', - 'Software' => '{{ nexpose[node.software] }}' + 'Software' => '{{ nexpose[node.software] }}', + 'Fingerprints' => '{{ nexpose[node.fingerprints] }}' }, full_scan: { 'Title' => '{{ nexpose[scan.name] }} ({{ nexpose[scan.scan_id] }})', diff --git a/lib/tasks/thorfile.rb b/lib/tasks/thorfile.rb index 05ad7a1..7ad8ecd 100644 --- a/lib/tasks/thorfile.rb +++ b/lib/tasks/thorfile.rb @@ -3,8 +3,25 @@ class NexposeTasks < Thor namespace "dradis:plugins:nexpose" - desc "upload FILE", "upload NeXpose results" - def upload(file_path) + desc "upload_full FILE", "upload NeXpose full results" + def upload_full(file_path) + detect_and_set_project_scope + + importer = Dradis::Plugins::Nexpose::Full::Importer.new(task_options) + importer.import(file: file_path) + end + + desc "upload_simple FILE", "upload NeXpose simple results" + def upload_simple(file_path) + detect_and_set_project_scope + + importer = Dradis::Plugins::Nexpose::Simple::Importer.new(task_options) + importer.import(file: file_path) + end + + private + + def process_upload(importer, file_path) require 'config/environment' unless File.exists?(file_path) @@ -12,10 +29,6 @@ def upload(file_path) exit -1 end - detect_and_set_project_scope - - importer = Dradis::Plugins::Nexpose::Importer.new(task_options) importer.import(file: file_path) end - end diff --git a/spec/nexpose/full/importer_spec.rb b/spec/nexpose/full/importer_spec.rb new file mode 100644 index 0000000..30702dd --- /dev/null +++ b/spec/nexpose/full/importer_spec.rb @@ -0,0 +1,108 @@ +# To run, execute from Dradis main app folder: +# bin/rspec [dradis-nexpose path]/spec/nexpose/full/importer_spec.rb +require 'rails_helper' +require 'ostruct' +require File.expand_path('../../../support/spec_macros.rb', __FILE__) + +include SpecMacros + +module Dradis::Plugins + describe Nexpose::Full::Importer do + before do + @fixtures_dir = File.expand_path('../../../fixtures/files/', __FILE__) + end + + before(:each) do + stub_content_service + @importer = described_class.new(content_service: @content_service) + end + + def run_import! + @importer.import(file: @fixtures_dir + '/full.xml') + end + + it 'creates nodes and associated notes as needed' do + expect(@content_service).to receive(:create_node).with(hash_including label: 'Nexpose Scan Summary').once + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include("#[Title]#\nUSDA_Internal (4)") + expect(args[:node].label).to eq('Nexpose Scan Summary') + end.once + + expect(@content_service).to receive(:create_node) do |args| + expect(args[:label]).to eq('1.1.1.1') + expect(args[:type]).to eq(:host) + create(:node, args.except(:type)) + end + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include("#[Title]#\n1.1.1.1") + expect(args[:text]).to include("#[Fingerprints]#\nIOS") + expect(args[:node].label).to eq('1.1.1.1') + end.once + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include("#[Title]#\nService name: NTP") + expect(args[:node].label).to eq('1.1.1.1') + end.once + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include("#[Title]#\nService name: SNMP") + expect(args[:node].label).to eq('1.1.1.1') + end.once + + run_import! + end + + it 'creates issues from vulnerability elements as needed' do + expect(@content_service).to receive(:create_issue) do |args| + expect(args[:text]).to include("#[Title]#\nApache HTTPD: error responses can expose cookies (CVE-2012-0053)") + expect(args[:id]).to eq('ntp-clock-variables-disclosure') + OpenStruct.new(args) + end.once + + expect(@content_service).to receive(:create_issue) do |args| + expect(args[:text]).to include("#[Title]#\nApache HTTPD: ETag Inode Information Leakage (CVE-2003-1418)") + expect(args[:id]).to eq('test-02') + OpenStruct.new(args) + end.once + + run_import! + end + + it 'creates evidence as needed' do + expect(@content_service).to receive(:create_evidence) do |args| + expect(args[:content]).to include('The following NTP variables were found from a readvar') + expect(args[:issue].id).to eq('ntp-clock-variables-disclosure') + expect(args[:node].label).to eq('1.1.1.1') + end.once + + expect(@content_service).to receive(:create_evidence) do |args| + expect(args[:content]).to include('Missing HTTP header "Content-Security-Policy"') + expect(args[:issue].id).to eq('test-02') + expect(args[:node].label).to eq('1.1.1.1') + end + + run_import! + end + + describe 'With duplicate nodes' do + it 'creates evidence for each instance of the node' do + expect(@content_service).to receive(:create_node).with(hash_including label: 'Nexpose Scan Summary').once + expect(@content_service).to receive(:create_node) do |args| + expect(args[:label]).to eq('1.1.1.1') + expect(args[:type]).to eq(:host) + create(:node, args.except(:type)) + end + + expect(@content_service).to receive(:create_evidence) do |args| + expect(args[:content]).to include("#[ID]#\nntp-clock-variables-disclosure\n\n") + expect(args[:issue].id).to eq('ntp-clock-variables-disclosure') + expect(args[:node].label).to eq('1.1.1.1') + end.twice + + @importer.import(file: @fixtures_dir + '/full_with_duplicate_node.xml') + end + end + end +end diff --git a/spec/nexpose/simple/importer_spec.rb b/spec/nexpose/simple/importer_spec.rb new file mode 100644 index 0000000..ac74dea --- /dev/null +++ b/spec/nexpose/simple/importer_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' +require 'ostruct' +require File.expand_path('../../../support/spec_macros.rb', __FILE__) + +include SpecMacros + +module Dradis::Plugins + describe Nexpose::Simple::Importer do + before do + @fixtures_dir = File.expand_path('../../../fixtures/files/', __FILE__) + end + + before(:each) do + stub_content_service + @importer = described_class.new(content_service: @content_service) + end + + def run_import! + @importer.import(file: @fixtures_dir + '/simple.xml') + end + + it 'creates nodes and associated notes as needed' do + expect(@content_service).to receive(:create_node).with(hash_including label: '1.1.1.1', type: :host).once + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include('Host Description : Linux 2.6.9-89.ELsmp') + expect(args[:text]).to include('Scanner Fingerprint certainty : 0.80') + expect(args[:node].label).to eq('1.1.1.1') + end.once + + expect(@content_service).to receive(:create_node) do |args| + expect(args[:label]).to eq('Generic Findings') + expect(args[:parent].label).to eq('1.1.1.1') + OpenStruct.new(args) + end.once + + expect(@content_service).to receive(:create_node) do |args| + expect(args[:label]).to eq('udp-000') + expect(args[:parent].label).to eq('1.1.1.1') + OpenStruct.new(args) + end.once + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include("#[Id]#\nntpd-crypto") + expect(args[:text]).to include("#[host]#\n1.1.1.1") + expect(args[:node].label).to eq('udp-000') + end.once + + expect(@content_service).to receive(:create_note) do |args| + expect(args[:text]).to include("#[Id]#\nntp-clock-radio") + expect(args[:text]).to include("#[host]#\n1.1.1.1") + expect(args[:node].label).to eq('udp-000') + end.once + + run_import! + end + end +end diff --git a/spec/nexpose_upload_spec.rb b/spec/nexpose_upload_spec.rb deleted file mode 100644 index 78f40eb..0000000 --- a/spec/nexpose_upload_spec.rb +++ /dev/null @@ -1,221 +0,0 @@ -require 'rails_helper' -require 'ostruct' - -describe 'Nexpose upload plugin' do - before do - @fixtures_dir = File.expand_path('../fixtures/files/', __FILE__) - end - - describe 'importer' do - before(:each) do - # Stub template service - templates_dir = File.expand_path('../../templates', __FILE__) - expect_any_instance_of(Dradis::Plugins::TemplateService) - .to receive(:default_templates_dir).and_return(templates_dir) - - # Init services - plugin = Dradis::Plugins::Nexpose - - @content_service = Dradis::Plugins::ContentService::Base.new( - logger: Logger.new(STDOUT), - plugin: plugin - ) - - @importer = plugin::Importer.new( - content_service: @content_service, - ) - - # Stub dradis-plugins methods - # - # They return their argument hashes as objects mimicking - # Nodes, Issues, etc - allow(@content_service).to receive(:create_node) do |args| - OpenStruct.new(args) - end - allow(@content_service).to receive(:create_note) do |args| - OpenStruct.new(args) - end - allow(@content_service).to receive(:create_issue) do |args| - OpenStruct.new(args) - end - allow(@content_service).to receive(:create_evidence) do |args| - OpenStruct.new(args) - end - end - - describe 'Importer: Simple' do - it 'creates nodes, issues, notes and an evidences as needed' do - - expect(@content_service).to receive(:create_node).with(hash_including label: '1.1.1.1', type: :host).once - - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include('Host Description : Linux 2.6.9-89.ELsmp') - expect(args[:text]).to include('Scanner Fingerprint certainty : 0.80') - expect(args[:node].label).to eq('1.1.1.1') - end.once - - expect(@content_service).to receive(:create_node) do |args| - expect(args[:label]).to eq('Generic Findings') - expect(args[:parent].label).to eq('1.1.1.1') - OpenStruct.new(args) - end.once - - expect(@content_service).to receive(:create_node) do |args| - expect(args[:label]).to eq('udp-000') - expect(args[:parent].label).to eq('1.1.1.1') - OpenStruct.new(args) - end.once - - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include("#[Id]#\nntpd-crypto") - expect(args[:text]).to include("#[host]#\n1.1.1.1") - expect(args[:node].label).to eq('udp-000') - end.once - - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include("#[Id]#\nntp-clock-radio") - expect(args[:text]).to include("#[host]#\n1.1.1.1") - expect(args[:node].label).to eq('udp-000') - end.once - - @importer.import(file: @fixtures_dir + '/simple.xml') - end - end - - describe 'Importer: Full' do - it 'creates nodes, issues, notes and an evidences as needed' do - expect(@content_service).to receive(:create_node).with(hash_including label: 'Nexpose Scan Summary').once - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include("#[Title]#\nUSDA_Internal (4)") - expect(args[:node].label).to eq('Nexpose Scan Summary') - end.once - - expect(@content_service).to receive(:create_node) do |args| - expect(args[:label]).to eq('1.1.1.1') - expect(args[:type]).to eq(:host) - create(:node, args.except(:type)) - end - - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include("#[Title]#\n1.1.1.1") - expect(args[:node].label).to eq('1.1.1.1') - end.once - - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include("#[Title]#\nService name: NTP") - expect(args[:node].label).to eq('1.1.1.1') - end.once - - expect(@content_service).to receive(:create_note) do |args| - expect(args[:text]).to include("#[Title]#\nService name: SNMP") - expect(args[:node].label).to eq('1.1.1.1') - end.once - - expect(@content_service).to receive(:create_issue) do |args| - expect(args[:text]).to include("#[Title]#\nApache HTTPD: error responses can expose cookies (CVE-2012-0053)") - expect(args[:id]).to eq('ntp-clock-variables-disclosure') - OpenStruct.new(args) - end.once - - expect(@content_service).to receive(:create_issue) do |args| - expect(args[:text]).to include("#[Title]#\nApache HTTPD: ETag Inode Information Leakage (CVE-2003-1418)") - expect(args[:id]).to eq('test-02') - OpenStruct.new(args) - end.once - - expect(@content_service).to receive(:create_evidence) do |args| - expect(args[:content]).to include("#[ID]#\nntp-clock-variables-disclosure\n\n") - expect(args[:issue].id).to eq('ntp-clock-variables-disclosure') - expect(args[:node].label).to eq('1.1.1.1') - end.once - - expect(@content_service).to receive(:create_evidence) do |args| - expect(args[:content]).to include("#[ID]#\ntest-02\n\n") - expect(args[:issue].id).to eq('test-02') - expect(args[:node].label).to eq('1.1.1.1') - end.once - - @importer.import(file: @fixtures_dir + '/full.xml') - - expect(Node.find_by(label: '1.1.1.1').properties[:os]).to eq('IOS') - end - - it 'wraps ciphers inside ssl issues in code blocks' do - expect(@content_service).to receive(:create_issue) do |args| - expect(args[:text]).to include('bc. ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256') - OpenStruct.new(args) - end.once - - @importer.import(file: @fixtures_dir + '/ssl.xml') - end - - # Regression test for github.com/dradis/dradis-nexpose/issues/1 - it 'populates solutions regardless of if they are wrapped in paragraphs or lists' do - expect(@content_service).to receive(:create_issue) do |args| - expect(args[:text]).to include("#[Solution]#\n\nApache HTTPD >= 2.0 and < 2.0.65") - OpenStruct.new(args) - end.once - - expect(@content_service).to receive(:create_issue) do |args| - expect(args[:text]).to include("#[Solution]#\n") - expect(args[:text]).to include('You can remove inode information from the ETag header') - OpenStruct.new(args) - end.once - - @importer.import(file: @fixtures_dir + '/full.xml') - end - - it 'populates tests regardless of if they contain paragraphs or containerblockelements' do - expect(@content_service).to receive(:create_evidence) do |args| - expect(args[:content]).to include("#[Content]#\nThe following NTP variables") - OpenStruct.new(args) - end.once - - expect(@content_service).to receive(:create_evidence) do |args| - expect(args[:content]).to include("#[Content]#\nVulnerable URL:") - OpenStruct.new(args) - end.once - - @importer.import(file: @fixtures_dir + '/full.xml') - end - - it 'transforms html entities (< and >)' do - expect(@content_service).to receive(:create_issue) do |args| - expect(args[:text]).to include("#[Solution]#\n\nApache HTTPD >= 2.0 and < 2.0.65") - OpenStruct.new(args) - end - - @importer.import(file: @fixtures_dir + '/full.xml') - end - end - - describe 'Importer: Full with duplicate nodes' do - it 'creates evidence for each instance of the node' do - expect(@content_service).to receive(:create_node).with(hash_including label: 'Nexpose Scan Summary').once - expect(@content_service).to receive(:create_node) do |args| - expect(args[:label]).to eq('1.1.1.1') - expect(args[:type]).to eq(:host) - create(:node, args.except(:type)) - end - - expect(@content_service).to receive(:create_evidence) do |args| - expect(args[:content]).to include("#[ID]#\nntp-clock-variables-disclosure\n\n") - expect(args[:issue].id).to eq('ntp-clock-variables-disclosure') - expect(args[:node].label).to eq('1.1.1.1') - end.twice - - @importer.import(file: @fixtures_dir + '/full_with_duplicate_node.xml') - end - end - end - - it 'parses the fingerprints field' do - doc = Nokogiri::XML(File.read(@fixtures_dir + '/full.xml')) - - ts = Dradis::Plugins::TemplateService.new(plugin: Dradis::Plugins::Nexpose) - ts.set_template(template: 'full_node', content: "#[Fingerprints]#\n%node.fingerprints%\n") - result = ts.process_template(data: doc.at_xpath('//nodes/node'), template: 'full_node') - - expect(result).to include('IOS') - end -end diff --git a/spec/support/spec_macros.rb b/spec/support/spec_macros.rb new file mode 100644 index 0000000..c6181f3 --- /dev/null +++ b/spec/support/spec_macros.rb @@ -0,0 +1,26 @@ +module SpecMacros + extend ActiveSupport::Concern + + def stub_content_service + @content_service = Dradis::Plugins::ContentService::Base.new( + logger: Logger.new(STDOUT), + plugin: Dradis::Plugins::Nexpose + ) + + # Stub dradis-plugins methods + # They return their argument hashes as objects mimicking + # nodes, issues, etc + allow(@content_service).to receive(:create_node) do |args| + OpenStruct.new(args) + end + allow(@content_service).to receive(:create_note) do |args| + OpenStruct.new(args) + end + allow(@content_service).to receive(:create_issue) do |args| + OpenStruct.new(args) + end + allow(@content_service).to receive(:create_evidence) do |args| + OpenStruct.new(args) + end + end +end From 1ce63f645c97879d06f2c605dbbbfc4d99d57a85 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 4 Sep 2024 16:15:45 -0400 Subject: [PATCH 3/6] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 202133c..2c50797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +v4.XX.X (Month 2024) +- Separate general importer into Full & Simple importers + v4.13.0 (July 2024) - No changes From c933baa0c155d8b0ae8c95a1f88e6fa4b0345f83 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 4 Sep 2024 16:16:28 -0400 Subject: [PATCH 4/6] bump version --- CHANGELOG.md | 2 +- lib/dradis/plugins/nexpose/gem_version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c50797..f938239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v4.XX.X (Month 2024) +v4.14.0 (Month 2024) - Separate general importer into Full & Simple importers v4.13.0 (July 2024) diff --git a/lib/dradis/plugins/nexpose/gem_version.rb b/lib/dradis/plugins/nexpose/gem_version.rb index bf11c5b..49d681d 100644 --- a/lib/dradis/plugins/nexpose/gem_version.rb +++ b/lib/dradis/plugins/nexpose/gem_version.rb @@ -8,7 +8,7 @@ def self.gem_version module VERSION MAJOR = 4 - MINOR = 13 + MINOR = 14 TINY = 0 PRE = nil From f297e4d828f8201e2261adcae4c128063bc74155 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 13 Sep 2024 11:36:32 -0400 Subject: [PATCH 5/6] use consistent thor task naming --- lib/tasks/thorfile.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/tasks/thorfile.rb b/lib/tasks/thorfile.rb index 7ad8ecd..7846bac 100644 --- a/lib/tasks/thorfile.rb +++ b/lib/tasks/thorfile.rb @@ -1,18 +1,18 @@ class NexposeTasks < Thor include Rails.application.config.dradis.thor_helper_module - namespace "dradis:plugins:nexpose" + namespace 'dradis:plugins:nexpose:upload' - desc "upload_full FILE", "upload NeXpose full results" - def upload_full(file_path) + desc 'full FILE', 'upload NeXpose full results' + def full(file_path) detect_and_set_project_scope importer = Dradis::Plugins::Nexpose::Full::Importer.new(task_options) importer.import(file: file_path) end - desc "upload_simple FILE", "upload NeXpose simple results" - def upload_simple(file_path) + desc 'simple FILE', 'upload NeXpose simple results' + def simple(file_path) detect_and_set_project_scope importer = Dradis::Plugins::Nexpose::Simple::Importer.new(task_options) From 78194f4c43d06f5999034fdd7414994121c7cd60 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 4 Oct 2024 10:56:49 -0400 Subject: [PATCH 6/6] use dradis::plugins::specmacros to stub content service --- spec/nexpose/full/importer_spec.rb | 4 ++-- spec/nexpose/simple/importer_spec.rb | 6 ++++-- spec/support/spec_macros.rb | 26 -------------------------- 3 files changed, 6 insertions(+), 30 deletions(-) delete mode 100644 spec/support/spec_macros.rb diff --git a/spec/nexpose/full/importer_spec.rb b/spec/nexpose/full/importer_spec.rb index 30702dd..bd6d80b 100644 --- a/spec/nexpose/full/importer_spec.rb +++ b/spec/nexpose/full/importer_spec.rb @@ -2,9 +2,9 @@ # bin/rspec [dradis-nexpose path]/spec/nexpose/full/importer_spec.rb require 'rails_helper' require 'ostruct' -require File.expand_path('../../../support/spec_macros.rb', __FILE__) +require File.expand_path('../../../../../dradis-plugins/spec/support/spec_macros.rb', __FILE__) -include SpecMacros +include Dradis::Plugins::SpecMacros module Dradis::Plugins describe Nexpose::Full::Importer do diff --git a/spec/nexpose/simple/importer_spec.rb b/spec/nexpose/simple/importer_spec.rb index ac74dea..9168ea4 100644 --- a/spec/nexpose/simple/importer_spec.rb +++ b/spec/nexpose/simple/importer_spec.rb @@ -1,8 +1,10 @@ +# To run, execute from Dradis main app folder: +# bin/rspec [dradis-nexpose path]/spec/nexpose/simple/importer_spec.rb require 'rails_helper' require 'ostruct' -require File.expand_path('../../../support/spec_macros.rb', __FILE__) +require File.expand_path('../../../../../dradis-plugins/spec/support/spec_macros.rb', __FILE__) -include SpecMacros +include Dradis::Plugins::SpecMacros module Dradis::Plugins describe Nexpose::Simple::Importer do diff --git a/spec/support/spec_macros.rb b/spec/support/spec_macros.rb deleted file mode 100644 index c6181f3..0000000 --- a/spec/support/spec_macros.rb +++ /dev/null @@ -1,26 +0,0 @@ -module SpecMacros - extend ActiveSupport::Concern - - def stub_content_service - @content_service = Dradis::Plugins::ContentService::Base.new( - logger: Logger.new(STDOUT), - plugin: Dradis::Plugins::Nexpose - ) - - # Stub dradis-plugins methods - # They return their argument hashes as objects mimicking - # nodes, issues, etc - allow(@content_service).to receive(:create_node) do |args| - OpenStruct.new(args) - end - allow(@content_service).to receive(:create_note) do |args| - OpenStruct.new(args) - end - allow(@content_service).to receive(:create_issue) do |args| - OpenStruct.new(args) - end - allow(@content_service).to receive(:create_evidence) do |args| - OpenStruct.new(args) - end - end -end