lib/nexpose/report.rb
module Nexpose
class Connection
include XMLUtils
# Provide a listing of all report definitions the user can access on the
# Security Console.
#
# @return [Array[ReportConfigSummary]] List of current report configuration.
#
def list_reports
r = execute(make_xml('ReportListingRequest'))
reports = []
if r.success
r.res.elements.each('//ReportConfigSummary') do |report|
reports << ReportConfigSummary.parse(report)
end
end
reports
end
alias reports list_reports
# Generate a new report using the specified report definition.
def generate_report(report_id, wait = false)
xml = make_xml('ReportGenerateRequest', { 'report-id' => report_id })
response = execute(xml)
if response.success
response.res.elements.each('//ReportSummary') do |summary|
summary = ReportSummary.parse(summary)
# If not waiting or the report is finished, return now.
return summary unless wait && summary.status == 'Started'
end
end
so_far = 0
while wait
summary = last_report(report_id)
return summary unless summary.status == 'Started'
sleep 5
so_far += 5
if (so_far % 60).zero?
puts "Still waiting. Current status: #{summary.status}"
end
end
nil
end
# Provide a history of all reports generated with the specified report
# definition.
def report_history(report_config_id)
xml = make_xml('ReportHistoryRequest', { 'reportcfg-id' => report_config_id })
ReportSummary.parse_all(execute(xml))
end
# Get details of the last report generated with the specified report id.
def last_report(report_config_id)
history = report_history(report_config_id)
history.sort { |a, b| b.generated_on <=> a.generated_on }.first
end
# Delete a previously generated report.
#
# @param [Fixnum] report_id ID of individual report to delete.
#
def delete_report(report_id)
xml = make_xml('ReportDeleteRequest', { 'report-id' => report_id })
execute(xml).success
end
# Delete a previously generated report definition.
# Also deletes any reports generated from that configuration.
#
# @param [Fixnum] report_config_id ID of the report configuration to remove.
#
def delete_report_config(report_config_id)
xml = make_xml('ReportDeleteRequest', { 'reportcfg-id' => report_config_id })
execute(xml).success
end
end
# Data object for report configuration information.
# Not meant for use in creating new configurations.
#
class ReportConfigSummary
# The report definition (config) ID.
attr_reader :config_id
# The report config name.
attr_reader :name
# The ID of the report template.
attr_reader :template_id
# The current status of the report.
# One of: Started|Generated|Failed|Aborted|Unknown
attr_reader :status
# The date and time the report was generated, in ISO 8601 format.
attr_reader :generated_on
# The URL to use to access the report (not set for database exports).
attr_reader :uri
# The visibility (scope) of the report definition.
# One of: (global|silo).
attr_reader :scope
def initialize(config_id, name, template_id, status, generated_on, uri, scope)
@config_id = config_id.to_i
@name = name
@template_id = template_id
@status = status
@generated_on = generated_on
@uri = uri
@scope = scope
end
def self.parse(xml)
ReportConfigSummary.new(xml.attributes['cfg-id'].to_i,
xml.attributes['name'],
xml.attributes['template-id'],
xml.attributes['status'],
xml.attributes['generated-on'],
xml.attributes['report-URI'],
xml.attributes['scope'])
end
end
# Summary of a single report.
#
class ReportSummary
# The ID of the generated report.
attr_reader :id
# The report definition (configuration) ID.
attr_reader :config_id
# The current status of the report.
# One of: Started|Generated|Failed|Aborted|Unknown
attr_reader :status
# The date and time the report was generated, in ISO 8601 format.
attr_reader :generated_on
# The relative URI to use to access the report.
attr_reader :uri
def initialize(id, config_id, status, generated_on, uri)
@id = id
@config_id = config_id.to_i
@status = status
@generated_on = generated_on
@uri = uri
end
# Delete this report.
def delete(connection)
connection.delete_report(@id)
end
def self.parse(xml)
ReportSummary.new(xml.attributes['id'],
xml.attributes['cfg-id'],
xml.attributes['status'],
xml.attributes['generated-on'],
xml.attributes['report-URI'])
end
def self.parse_all(response)
summaries = []
if response.success
response.res.elements.each('//ReportSummary') do |summary|
summaries << ReportSummary.parse(summary)
end
end
summaries
end
end
# Definition object for an adhoc report configuration.
#
# NOTE: XML reports only return the text of the report, but no images.
#
class AdhocReportConfig
# The ID of the report template used.
attr_accessor :template_id
# Format. One of: pdf|html|rtf|xml|text|csv|db|raw-xml|raw-xml-v2|ns-xml|qualys-xml
attr_accessor :format
attr_accessor :owner
attr_accessor :time_zone
attr_accessor :language
# Array of filters associated with this report.
attr_accessor :filters
# Baseline comparison highlights the changes between two scans, including
# newly discovered assets, services and vulnerabilities, assets and services
# that are no longer available and vulnerabilities that were mitigated or
# fixed. The current scan results can be compared against the results of the
# first scan, the most recent (previous) scan, or the scan results from a
# particular date.
attr_accessor :baseline
def initialize(template_id, format, site_id = nil, owner = nil, time_zone = nil)
@template_id = template_id
@format = format
@owner = owner
@time_zone = time_zone
@filters = []
@filters << Filter.new('site', site_id) if site_id
end
# Add a new filter to this report configuration.
def add_filter(type, id)
filters << Filter.new(type, id)
end
# Add the common vulnerability status filters as used by the UI for export
# and jasper report templates (the default filters). Recommended for reports
# that do not require 'not vulnerable' results to be included. The following
# statuses are added: vulnerable-exploted, vulnerable-version, and potential.
def add_common_vuln_status_filters
['vulnerable-exploited', 'vulnerable-version', 'potential'].each do |vuln_status|
filters << Filter.new('vuln-status', vuln_status)
end
end
def to_xml
xml = %(<AdhocReportConfig format="#{@format}" template-id="#{@template_id}")
xml << %( owner="#{@owner}") if @owner
xml << %( timezone="#{@time_zone}") if @time_zone
xml << %( language="#{@language}") if @language
xml << '>'
xml << '<Filters>'
@filters.each { |filter| xml << filter.to_xml }
xml << '</Filters>'
xml << %(<Baseline compareTo="#{@baseline}"/>) if @baseline
xml << '</AdhocReportConfig>'
end
# Generate a report once using a simple configuration.
#
# For XML-based reports, only the textual report is returned and not any images.
#
# @param [Connection] connection Nexpose connection.
# @param [Fixnum] timeout How long, in seconds, to wait for the report to
# generate. Larger reports can take a significant amount of time.
# @param [Boolean] raw Whether to bypass response parsing an use the raw
# response. If this option is used, error will only be exposed by
# examining Connection#response_xml.
# @return Report in text format except for PDF, which returns binary data.
#
def generate(connection, timeout = 300, raw = false)
xml = %(<ReportAdhocGenerateRequest session-id="#{connection.session_id}">)
xml << to_xml
xml << '</ReportAdhocGenerateRequest>'
response = connection.execute(xml, '1.1', timeout: timeout, raw: raw)
if response.success
content_type_response = response.raw_response.header['Content-Type']
if content_type_response =~ /multipart\/mixed;\s*boundary=([^\s]+)/
# Nexpose sends an incorrect boundary format which breaks parsing
# e.g., boundary=XXX; charset=XXX
# Fix by removing everything from the last semi-colon onward.
last_semi_colon_index = content_type_response.index(/;/, content_type_response.index(/boundary/))
content_type_response = content_type_response[0, last_semi_colon_index]
data = 'Content-Type: ' + content_type_response + "\r\n\r\n" + response.raw_response_data
doc = Rexlite::MIME::Message.new(data)
doc.parts.each do |part|
if /.*base64.*/ =~ part.header.to_s
if @format =~ /(?:ht|x)ml/
if part.header.to_s =~ %r(text/xml)
return part.content.unpack('m*')[0].to_s
elsif part.header.to_s =~ %r(text/html)
return part.content.unpack('m*')[0].to_s
end
else # text|pdf|csv|rtf
return part.content.unpack('m*')[0]
end
end
end
end
end
end
end
# Definition object for a report configuration.
class ReportConfig < AdhocReportConfig
# The ID of the report definition (config).
# Use -1 to create a new definition.
attr_accessor :id
# The unique name assigned to the report definition.
attr_accessor :name
# Description associated with this report.
attr_accessor :description
# Array of user IDs which have access to resulting reports.
attr_accessor :users
# Configuration of when a report is generated.
attr_accessor :frequency
# Report delivery configuration.
attr_accessor :delivery
# Database export configuration.
attr_accessor :db_export
# Construct a basic ReportConfig object.
def initialize(name, template_id, format, id = -1, owner = nil, time_zone = nil)
@name = name
@template_id = template_id
@format = format
@id = id
@owner = owner
@time_zone = time_zone
@filters = []
@users = []
end
# Retrieve the configuration for an existing report definition.
def self.load(connection, report_config_id)
xml = %(<ReportConfigRequest session-id='#{connection.session_id}' reportcfg-id='#{report_config_id}'/>)
ReportConfig.parse(connection.execute(xml))
end
alias get load
# Build and save a report configuration against the specified site using
# the supplied type and format.
#
# Returns the new configuration.
def self.build(connection, site_id, site_name, type, format, generate_now = false)
name = %(#{site_name} #{type} report in #{format})
config = ReportConfig.new(name, type, format)
config.frequency = Frequency.new(true, false) unless generate_now
config.filters << Filter.new('site', site_id)
config.save(connection, generate_now)
config
end
# Save the configuration of this report definition.
def save(connection, generate_now = false)
xml = %(<ReportSaveRequest session-id="#{connection.session_id}" generate-now="#{generate_now ? 1 : 0}">)
xml << to_xml
xml << '</ReportSaveRequest>'
response = connection.execute(xml)
if response.success
@id = response.attributes['reportcfg-id'].to_i
end
end
# Generate a new report using this report definition.
def generate(connection, wait = false)
connection.generate_report(@id, wait)
end
# Delete this report definition from the Security Console.
# Deletion will also remove all reports previously generated from the
# configuration.
def delete(connection)
connection.delete_report_config(@id)
end
include Sanitize
def to_xml
xml = %(<ReportConfig format="#{@format}" id="#{@id}" name="#{replace_entities(@name)}" template-id="#{@template_id}")
xml << %( owner="#{@owner}") if @owner
xml << %( timezone="#{@time_zone}") if @time_zone
xml << %( language="#{@language}") if @language
xml << '>'
xml << %(<description>#{@description}</description>) if @description
xml << '<Filters>'
@filters.each { |filter| xml << filter.to_xml }
xml << '</Filters>'
xml << '<Users>'
@users.each { |user| xml << %(<user id="#{user}"/>) }
xml << '</Users>'
xml << %(<Baseline compareTo="#{@baseline}"/>) if @baseline
xml << @frequency.to_xml if @frequency
xml << @delivery.to_xml if @delivery
xml << @db_export.to_xml if @db_export
xml << '</ReportConfig>'
end
def self.parse(xml)
xml.res.elements.each('//ReportConfig') do |cfg|
config = ReportConfig.new(cfg.attributes['name'],
cfg.attributes['template-id'],
cfg.attributes['format'],
cfg.attributes['id'].to_i,
cfg.attributes['owner'].to_i,
cfg.attributes['timezone'])
cfg.elements.each('//description') do |desc|
config.description = desc.text
end
config.filters = Filter.parse(xml)
cfg.elements.each('//user') do |user|
config.users << user.attributes['id'].to_i
end
cfg.elements.each('//Baseline') do |baseline|
config.baseline = baseline.attributes['compareTo']
end
config.frequency = Frequency.parse(cfg)
config.delivery = Delivery.parse(cfg)
config.db_export = DBExport.parse(cfg)
return config
end
nil
end
end
# Object that represents a report filter which determines which sites, asset
# groups, and/or assets that a report is run against.
#
# The configuration must include at least one of asset, site,
# group (asset group) or scan filter to define the scope of report.
# The vuln-status filter can be used only with raw report formats: csv
# or raw_xml. If the vuln-status filter is not included in the configuration,
# all the vulnerability test results (including invulnerable instances) are
# exported by default in csv and raw_xml reports.
#
class Filter
include Sanitize
# The ID of the specific site, group, asset, or scan.
# For scan, this can also be "last" for the most recently run scan.
# For vuln-status, the ID can have one of the following values:
# 1. vulnerable-exploited (The check was positive. An exploit verified the vulnerability.)
# 2. vulnerable-version (The check was positive. The version of the scanned service or software is associated with known vulnerabilities.)
# 3. potential (The check for a potential vulnerability was positive.)
# These values are supported for CSV and XML formats.
attr_reader :id
# One of: site|group|device|scan|vuln-categories|vuln-severity|vuln-status|cyberscope-component|cyberscope-bureau|cyberscope-enclave|tag
attr_reader :type
def initialize(type, id)
@type = type
@id = id
end
def to_xml
%(<filter id="#{replace_entities(@id)}" type="#{@type}" />)
end
def ==(other)
other.equal?(self) || (other.instance_of?(self.class) && other.type == @type && other.id == @id)
end
def self.parse(xml)
filters = []
xml.res.elements.each('//Filters/filter') do |filter|
filters << Filter.new(filter.attributes['type'], filter.attributes['id'])
end
filters
end
end
# Data object associated with when a report is generated.
#
class Frequency
# Will the report be generated after a scan completes (true),
# or is it ad hoc/scheduled (false).
attr_accessor :after_scan
# Whether or not a scan is scheduled.
attr_accessor :scheduled
# Schedule associated with the report.
attr_accessor :schedule
def initialize(after_scan, scheduled, schedule = nil)
@after_scan = after_scan
@scheduled = scheduled
@schedule = schedule
end
def to_xml
xml = %(<Generate after-scan="#{@after_scan ? 1 : 0}" schedule="#{@scheduled ? 1 : 0}">)
xml << @schedule.to_xml if @schedule
xml << '</Generate>'
end
def self.parse(xml)
xml.elements.each('//Generate') do |generate|
if generate.attributes['after-scan'] == '1'
return Frequency.new(true, false)
else
if generate.attributes['schedule'] == '1'
generate.elements.each('Schedule') do |sched|
schedule = Schedule.parse(sched)
return Frequency.new(false, true, schedule)
end
end
return Frequency.new(false, false)
end
end
nil
end
end
# Data object for configuration of where a report is stored or delivered.
#
class Delivery
# Whether to store report on server.
attr_accessor :store_on_server
# Directory location to store report in (for non-default storage).
attr_accessor :location
# E-mail configuration.
attr_accessor :email
def initialize(store_on_server, location = nil, email = nil)
@store_on_server = store_on_server
@location = location
@email = email
end
def to_xml
xml = '<Delivery>'
xml << %(<Storage storeOnServer="#{@store_on_server ? 1 : 0}">)
xml << %(<location>#{@location}</location>) if @location
xml << '</Storage>'
xml << @email.to_xml if @email
xml << '</Delivery>'
end
def self.parse(xml)
xml.elements.each('//Delivery') do
on_server = false
location = nil
xml.elements.each('//Storage') do |storage|
on_server = true if storage.attributes['storeOnServer'] == '1'
xml.elements.each('//location') do |loc|
location = loc.text
end
end
email = Email.parse(xml)
return Delivery.new(on_server, location, email)
end
nil
end
end
# Configuration structure for database exporting of reports.
#
class DBExport
# The DB type to export to.
attr_accessor :type
# Credentials needed to export to the specified database.
attr_accessor :credentials
# Map of parameters for this DB export configuration.
attr_accessor :parameters
def initialize(type)
@type = type
@parameters = {}
end
def to_xml
xml = %(<DBExport type="#{@type}">)
xml << @credentials.to_xml if @credentials
@parameters.each_pair do |name, value|
xml << %(<param name="#{name}">#{value}</param>)
end
xml << '</DBExport>'
end
def self.parse(xml)
xml.elements.each('//DBExport') do |dbexport|
config = DBExport.new(dbexport.attributes['type'])
config.credentials = ExportCredential.parse(xml)
xml.elements.each('//param') do |param|
config.parameters[param.attributes['name']] = param.text
end
return config
end
nil
end
end
# DBExport credentials configuration object.
#
# The user_id, password and realm attributes should ONLY be used
# if a security blob cannot be generated and the data is being
# transmitted/stored using external encryption (e.g., HTTPS).
#
class ExportCredential
# Security blob for exporting to a database.
attr_accessor :credential
attr_accessor :user_id
attr_accessor :password
# DB specific, usually the database name.
attr_accessor :realm
def initialize(credential)
@credential = credential
end
def to_xml
xml = '<credentials'
xml << %( userid="#{@user_id}") if @user_id
xml << %( password="#{@password}") if @password
xml << %( realm="#{@realm}") if @realm
xml << '>'
xml << @credential if @credential
xml << '</credentials>'
end
def self.parse(xml)
xml.elements.each('//credentials') do |creds|
credential = ExportCredential.new(creds.text)
# The following attributes may not exist.
credential.user_id = creds.attributes['userid']
credential.password = creds.attributes['password']
credential.realm = creds.attributes['realm']
return credential
end
nil
end
end
end