lib/nexpose/site.rb
module Nexpose
class Connection
include XMLUtils
# Retrieve a list of all sites the user is authorized to view or manage.
#
# @return [Array[SiteSummary]] Array of SiteSummary objects.
#
def list_sites
r = execute(make_xml('SiteListingRequest'))
arr = []
if r.success
r.res.elements.each('SiteListingResponse/SiteSummary') do |site|
arr << SiteSummary.new(site.attributes['id'].to_i,
site.attributes['name'],
site.attributes['description'],
site.attributes['riskfactor'].to_f,
site.attributes['riskscore'].to_f)
end
end
arr
end
alias sites list_sites
# Delete the specified site and all associated scan data.
#
# @return Whether or not the delete request succeeded.
#
def delete_site(site_id)
r = execute(make_xml('SiteDeleteRequest', { 'site-id' => site_id }))
r.success
end
# Retrieve a list of all previous scans of the site.
#
# @param [FixNum] site_id Site ID to request scan history for.
# @return [Array[ScanSummary]] Array of ScanSummary objects representing
# each scan run to date on the site provided.
#
def site_scan_history(site_id)
r = execute(make_xml('SiteScanHistoryRequest', { 'site-id' => site_id }))
scans = []
if r.success
r.res.elements.each('SiteScanHistoryResponse/ScanSummary') do |scan_event|
scans << ScanSummary.parse(scan_event)
end
end
scans
end
# Retrieve the scan summary statistics for the latest completed scan
# on a site.
#
# Method will not return data on an active scan.
#
# @param [FixNum] site_id Site ID to find latest scan for.
# @return [ScanSummary] details of the last completed scan for a site.
#
def last_scan(site_id)
site_scan_history(site_id).select(&:end_time).max_by(&:end_time)
end
# Retrieve a history of the completed scans for a given site.
#
# @param [FixNum] site_id Site ID to find scans for.
# @return [CompletedScan] details of the completed scans for the site.
#
def completed_scans(site_id)
table = { 'table-id' => 'site-completed-scans' }
data = DataTable._get_json_table(self, "/data/scan/site/#{site_id}", table)
data.map(&CompletedScan.method(:parse_json))
end
end
# Configuration object representing a Nexpose site.
#
# For a basic walk-through, see {https://github.com/rapid7/nexpose-client/wiki/Using-Sites}
class Site < APIObject
include JsonSerializer
# The site ID. An ID of -1 is used to designate a site that has not been
# saved to a Nexpose console.
attr_accessor :id
# Unique name of the site. Required.
attr_accessor :name
# Description of the site.
attr_accessor :description
# Included scan targets. May be IPv4, IPv6, DNS names, IPRanges or assetgroup ids.
attr_accessor :included_scan_targets
# Excluded scan targets. May be IPv4, IPv6, DNS names, IPRanges or assetgroup ids.
attr_accessor :excluded_scan_targets
# Scan template to use when starting a scan job. Default: full-audit-without-web-spider
attr_accessor :scan_template_id
# Friendly name of scan template to use when starting a scan job.
# Value is populated when a site is saved or loaded from a console.
attr_accessor :scan_template_name
# Scan Engine to use. Will use the default engine if nil or -1.
attr_accessor :engine_id
# [Array] Schedule starting dates and times for scans, and set their frequency.
attr_accessor :schedules
# [Array] Blackout starting dates, times and duration for blackout periods.
attr_accessor :blackouts
# The risk factor associated with this site. Default: 1.0
attr_accessor :risk_factor
# [Array] Collection of credentials associated with this site. Does not
# include shared credentials.
attr_accessor :site_credentials
# [Array] Collection of shared credentials associated with this site.
attr_accessor :shared_credentials
# [Array] Collection of web credentials associated with the site.
attr_accessor :web_credentials
# Scan the assets with last scanned engine or not.
attr_accessor :auto_engine_selection_enabled
# [Array] Collection of real-time alerts.
# @see Alert
# @see SMTPAlert
# @see SNMPAlert
# @see SyslogAlert
attr_accessor :alerts
# Information about the organization that this site belongs to.
# Used by some reports.
attr_accessor :organization
# [Array] List of user IDs for users who have access to the site.
attr_accessor :users
# Configuration version. Default: 3
attr_accessor :config_version
# Asset filter criteria if this site is dynamic.
attr_accessor :search_criteria
# discovery config of the discovery connection associated with this site if it is dynamic.
attr_accessor :discovery_config
# [Array[TagSummary]] Collection of TagSummary
attr_accessor :tags
# Site constructor. Both arguments are optional.
#
# @param [String] name Unique name of the site.
# @param [String] scan_template_id ID of the scan template to use.
def initialize(name = nil, scan_template_id = 'full-audit-without-web-spider')
@name = name
@scan_template_id = scan_template_id
@id = -1
@risk_factor = 1.0
@config_version = 3
@schedules = []
@blackouts = []
@included_scan_targets = { addresses: [], asset_groups: [] }
@excluded_scan_targets = { addresses: [], asset_groups: [] }
@site_credentials = []
@shared_credentials = []
@web_credentials = []
@alerts = []
@users = []
@tags = []
end
# Returns the array of included scan target addresses.
# @return [Array[IPRange|HostName]] Array of included addresses.
def included_addresses
@included_scan_targets[:addresses]
end
# Sets the array of included scan target addresses.
# @param [Array[IPRange|HostName]] new_addresses The new array of scan target addresses.
# @return [Array[IPRange|HostName]] Array of updated scan target addresses.
def included_addresses=(new_addresses)
@included_scan_targets[:addresses] = new_addresses
end
# Returns the array of IDs for included scan target asset groups.
# @return [Array[Fixnum]] Array of included asset groups.
def included_asset_groups
@included_scan_targets[:asset_groups]
end
# Sets the array of IDs for included scan target asset groups.
# @param [Array[Fixnum] new_asset_groups The new array of IDs for scan target asset groups.
# @return [Array[Fixnum] Array of IDs of the updated scan target asset groups.
def included_asset_groups=(new_asset_groups)
@included_scan_targets[:asset_groups] = new_asset_groups
end
# Returns the array of excluded scan target addresses.
# @return [Array[IPRange|HostName]] Array of excluded addresses.
def excluded_addresses
@excluded_scan_targets[:addresses]
end
# Sets the array of excluded scan target addresses.
# @param [Array[IPRange|HostName]] new_addresses The new array of scan target addresses.
# @return [Array[IPRange|HostName]] Array of updated scan target addresses.
def excluded_addresses=(new_addresses)
@excluded_scan_targets[:addresses] = new_addresses
end
# Returns the array of IDs for excluded scan target asset groups.
# @return [Array[Fixnum]] Array of IDs for excluded asset groups.
def excluded_asset_groups
@excluded_scan_targets[:asset_groups]
end
# Sets the array IDs for excluded scan target asset groups.
# @param [Array[Fixnum]] new_asset_groups The new array of IDs for scan target asset groups.
# @return [Array[Fixnum]] Array of IDs of the updated scan target asset groups.
def excluded_asset_groups=(new_asset_groups)
@excluded_scan_targets[:asset_groups] = new_asset_groups
end
# Returns true when the site is dynamic.
def is_dynamic?
!@discovery_config.nil?
end
alias dynamic? is_dynamic?
# Adds assets to this site by IP address range.
#
# @param [String] from Beginning IP address of a range.
# @param [String] to Ending IP address of a range.
def include_ip_range(from, to)
from_ip = IPAddr.new(from)
to_ip = IPAddr.new(to)
(from_ip..to_ip)
raise 'Invalid IP range specified' if (from_ip..to_ip).to_a.size.zero?
@included_scan_targets[:addresses] << IPRange.new(from, to)
rescue ArgumentError => e
raise "#{e.message} in given IP range"
end
# Remove assets to this site by IP address range.
#
# @param [String] from Beginning IP address of a range.
# @param [String] to Ending IP address of a range.
def remove_included_ip_range(from, to)
from_ip = IPAddr.new(from)
to_ip = IPAddr.new(to)
(from_ip..to_ip)
raise 'Invalid IP range specified' if (from_ip..to_ip).to_a.size.zero?
@included_scan_targets[:addresses].reject! { |t| t.eql? IPRange.new(from, to) }
rescue ArgumentError => e
raise "#{e.message} in given IP range"
end
# Adds an asset to this site included scan targets, resolving whether an IP or hostname is
# provided.
#
# @param [String] asset Identifier of an asset, either IP or host name.
#
def include_asset(asset)
@included_scan_targets[:addresses] << HostOrIP.convert(asset)
end
# Remove an asset to this site included scan targets, resolving whether an IP or hostname is
# provided.
#
# @param [String] asset Identifier of an asset, either IP or host name.
#
def remove_included_asset(asset)
@included_scan_targets[:addresses].reject! { |existing_asset| existing_asset == HostOrIP.convert(asset) }
end
# Adds assets to this site excluded scan targets by IP address range.
#
# @param [String] from Beginning IP address of a range.
# @param [String] to Ending IP address of a range.
def exclude_ip_range(from, to)
from_ip = IPAddr.new(from)
to_ip = IPAddr.new(to)
(from_ip..to_ip)
raise 'Invalid IP range specified' if (from_ip..to_ip).to_a.size.zero?
@excluded_scan_targets[:addresses] << IPRange.new(from, to)
rescue ArgumentError => e
raise "#{e.message} in given IP range"
end
# Remove assets from this site excluded scan targets by IP address range.
#
# @param [String] from Beginning IP address of a range.
# @param [String] to Ending IP address of a range.
def remove_excluded_ip_range(from, to)
from_ip = IPAddr.new(from)
to_ip = IPAddr.new(to)
(from_ip..to_ip)
raise 'Invalid IP range specified' if (from_ip..to_ip).to_a.size.zero?
@excluded_scan_targets[:addresses].reject! { |t| t.eql? IPRange.new(from, to) }
rescue ArgumentError => e
raise "#{e.message} in given IP range"
end
# Adds an asset to this site excluded scan targets, resolving whether an IP or hostname is
# provided.
#
# @param [String] asset Identifier of an asset, either IP or host name.
#
def exclude_asset(asset)
@excluded_scan_targets[:addresses] << HostOrIP.convert(asset)
end
# Removes an asset to this site excluded scan targets, resolving whether an IP or hostname is
# provided.
#
# @param [String] asset Identifier of an asset, either IP or host name.
#
def remove_excluded_asset(asset)
@excluded_scan_targets[:addresses].reject! { |existing_asset| existing_asset == HostOrIP.convert(asset) }
end
# Adds an asset group ID to this site included scan targets.
#
# @param [Integer] asset_group_id Identifier of an assetGroupID.
#
def include_asset_group(asset_group_id)
validate_asset_group(asset_group_id)
@included_scan_targets[:asset_groups] << asset_group_id.to_i
end
# Adds an asset group ID to this site included scan targets.
#
# @param [Integer] asset_group_id Identifier of an assetGroupID.
#
def remove_included_asset_group(asset_group_id)
validate_asset_group(asset_group_id)
@included_scan_targets[:asset_groups].reject! { |t| t.eql? asset_group_id.to_i }
end
# Adds an asset group ID to this site excluded scan targets.
#
# @param [Integer] asset_group_id Identifier of an assetGroupID.
#
def exclude_asset_group(asset_group_id)
validate_asset_group(asset_group_id)
@excluded_scan_targets[:asset_groups] << asset_group_id.to_i
end
# Adds an asset group ID to this site excluded scan targets.
#
# @param [Integer] asset_group_id Identifier of an assetGroupID.
#
def remove_excluded_asset_group(asset_group_id)
validate_asset_group(asset_group_id)
@excluded_scan_targets[:asset_groups].reject! { |t| t.eql? asset_group_id.to_i }
end
def validate_asset_group(asset_group_id)
begin
Integer(asset_group_id)
rescue ArgumentError => e
raise "Invalid asset_group id. #{e.message}"
end
raise 'Invalid asset_group id. Must be positive number.' if asset_group_id.to_i < 1
end
def add_user(user_id)
unless user_id.is_a?(Numeric) && user_id > 0
raise 'Invalid user id. A user id must be a positive number and refer to an existing system user.'
end
@users << { id: user_id }
end
def remove_user(user_id)
unless user_id.is_a?(Numeric) && user_id > 0
raise 'Invalid user id. A user id must be a positive number and refer to an existing system user.'
end
@users.delete_if { |h| h[:id] == user_id }
end
def self.from_hash(hash)
site = new(hash[:name], hash[:scan_template_id])
hash.each do |k, v|
site.instance_variable_set("@#{k}", v)
end
# Convert each string address to either a HostName or IPRange object
included_scan_targets = { addresses: [], asset_groups: [] }
site.included_scan_targets[:addresses].each { |asset| included_scan_targets[:addresses] << HostOrIP.convert(asset) }
included_scan_targets[:asset_groups] = site.included_scan_targets[:asset_groups]
site.included_scan_targets = included_scan_targets
excluded_scan_targets = { addresses: [], asset_groups: [] }
site.excluded_scan_targets[:addresses].each { |asset| excluded_scan_targets[:addresses] << HostOrIP.convert(asset) }
excluded_scan_targets[:asset_groups] = site.excluded_scan_targets[:asset_groups]
site.excluded_scan_targets = excluded_scan_targets
site
end
def to_json
JSON.generate(to_h)
end
def to_h
included_scan_targets = { addresses: @included_scan_targets[:addresses].compact,
asset_groups: @included_scan_targets[:asset_groups].compact }
excluded_scan_targets = { addresses: @excluded_scan_targets[:addresses].compact,
asset_groups: @excluded_scan_targets[:asset_groups].compact }
hash = { id: @id,
name: @name,
description: @description,
auto_engine_selection_enabled: @auto_engine_selection_enabled,
included_scan_targets: included_scan_targets,
excluded_scan_targets: excluded_scan_targets,
engine_id: @engine_id,
scan_template_id: @scan_template_id,
risk_factor: @risk_factor,
schedules: (@schedules || []).map(&:to_h),
shared_credentials: (@shared_credentials || []).map(&:to_h),
site_credentials: (@site_credentials || []).map(&:to_h),
web_credentials: (@web_credentials || []).map(&:to_h),
discovery_config: @discovery_config.to_h,
search_criteria: @search_criteria.to_h,
tags: (@tags || []).map(&:to_h),
alerts: (@alerts || []).map(&:to_h),
organization: @organization.to_h,
users: users }
# @TODO: Revisit this for 2.0.0 update
# Only pass in blackouts if they were actually specified (for backwards compatibility)
hash[:blackouts] = @blackouts.map(&:to_h) if @blackouts && @blackouts.any?
hash
end
require 'json'
# Load an site from the provided console.
#
# @param [Connection] nsc Active connection to a Nexpose console.
# @param [String] id Unique identifier of a site.
# @return [Site] The requested site, if found.
#
def self.load(nsc, id)
uri = "/api/2.1/site_configurations/#{id}"
resp = AJAX.get(nsc, uri, AJAX::CONTENT_TYPE::JSON)
hash = JSON.parse(resp, symbolize_names: true)
site = self.json_initializer(hash).deserialize(hash)
# Convert each string address to either a HostName or IPRange object
included_addresses = hash[:included_scan_targets][:addresses]
site.included_scan_targets[:addresses] = []
included_addresses.each { |asset| site.include_asset(asset) }
excluded_addresses = hash[:excluded_scan_targets][:addresses]
site.excluded_scan_targets[:addresses] = []
excluded_addresses.each { |asset| site.exclude_asset(asset) }
site.organization = Organization.create(site.organization)
site.schedules = (hash[:schedules] || []).map { |schedule| Nexpose::Schedule.from_hash(schedule) }
site.blackouts = (hash[:blackouts] || []).map { |blackout| Nexpose::Blackout.from_hash(blackout) }
site.site_credentials = hash[:site_credentials].map { |cred| Nexpose::SiteCredentials.new.object_from_hash(nsc, cred) }
site.shared_credentials = hash[:shared_credentials].map { |cred| Nexpose::SiteCredentials.new.object_from_hash(nsc, cred) }
site.discovery_config = Nexpose::DiscoveryConnection.new.object_from_hash(nsc, hash[:discovery_config]) unless hash[:discovery_config].nil?
site.search_criteria = Nexpose::DiscoveryConnection::Criteria.parseHash(hash[:search_criteria]) unless hash[:search_criteria].nil?
site.alerts = Alert.load_alerts(hash[:alerts])
site.tags = Tag.load_tags(hash[:tags])
site.web_credentials = hash[:web_credentials].map { |web_cred| (
web_cred[:service] == Nexpose::WebCredentials::WebAppAuthType::HTTP_HEADER ?
Nexpose::WebCredentials::Headers.new(web_cred[:name], web_cred[:baseURL], web_cred[:soft403Pattern], web_cred[:id]).object_from_hash(nsc, web_cred) :
Nexpose::WebCredentials::HTMLForms.new(web_cred[:name], web_cred[:baseURL], web_cred[:loginURL], web_cred[:soft403Pattern], web_cred[:id]).object_from_hash(nsc, web_cred)) }
site
end
def self.json_initializer(data)
new(data[:name], data[:scan_template_id])
end
# Copy an existing configuration from a Nexpose instance.
# Returned object will reset the site ID and append "Copy" to the existing
# name.
#
# @param [Connection] connection Connection to the security console.
# @param [Fixnum] id Site ID of an existing site.
# @return [Site] Site configuration loaded from a Nexpose console.
#
def self.copy(connection, id)
site = self.load(connection, id)
site.id = -1
site.name = "#{site.name} Copy"
site
end
# Saves this site to a Nexpose console.
# If the site is dynamic, connection and asset filter changes must be
# saved through the DiscoveryConnection#update_site call.
#
# @param [Connection] connection Connection to console where this site will be saved.
# @return [Fixnum] Site ID assigned to this configuration, if successful.
#
def save(connection)
new_site = @id == -1
if new_site
resp = AJAX.post(connection, '/api/2.1/site_configurations/', to_json, AJAX::CONTENT_TYPE::JSON)
@id = resp.to_i
else
AJAX.put(connection, "/api/2.1/site_configurations/#{@id}", to_json, AJAX::CONTENT_TYPE::JSON)
end
# Retrieve the scan engine and shared credentials and add them to the site configuration
site_config = Site.load(connection, @id)
@engine_id = site_config.engine_id
@shared_credentials = site_config.shared_credentials
@alerts = site_config.alerts
@id
end
# Delete this site from a Nexpose console.
#
# @param [Connection] connection Connection to console where this site will be saved.
# @return [Boolean] Whether or not the site was successfully deleted.
#
def delete(connection)
r = connection.execute(%(<SiteDeleteRequest session-id="#{connection.session_id}" site-id="#{@id}"/>))
r.success
end
# Scan this site.
#
# @param [Connection] connection Connection to console where scan will be launched.
# @param [String] sync_id Optional synchronization token.
# @param [Boolean] blackout_override Optional. Given suffencent permissions, force bypass blackout and start scan.
# @return [Scan] Scan launch information.
#
def scan(connection, sync_id = nil, blackout_override = false)
xml = REXML::Element.new('SiteScanRequest')
xml.add_attributes({ 'session-id' => connection.session_id,
'site-id' => @id,
'sync-id' => sync_id })
xml.add_attributes({ 'force' => true }) if blackout_override
response = connection.execute(xml, '1.1', timeout: connection.timeout)
Scan.parse(response.res) if response.success
end
end
# Object that represents the summary of a Nexpose Site.
#
class SiteSummary
# The Site ID.
attr_reader :id
# The Site Name.
attr_reader :name
# A Description of the Site.
attr_reader :description
# User assigned risk multiplier.
attr_reader :risk_factor
# Current computed risk score for the site.
attr_reader :risk_score
# Constructor
# SiteSummary(id, name, description, riskfactor = 1)
def initialize(id, name, description = nil, risk_factor = 1.0, risk_score = 0.0)
@id = id
@name = name
@description = description
@risk_factor = risk_factor
@risk_score = risk_score
end
end
# Object that represents a hostname to be added to a site.
#
class HostName
# Named host (usually DNS or Netbios name).
attr_accessor :host
def initialize(hostname)
@host = hostname
end
include Comparable
def <=>(other)
to_xml <=> other.to_xml
end
def eql?(other)
to_xml == other.to_xml
end
def hash
to_xml.hash
end
def as_xml
xml = REXML::Element.new('host')
xml.text = @host
xml
end
alias to_xml_elem as_xml
def to_xml
to_xml_elem.to_s
end
def to_s
@host.to_s
end
end
# Object that represents a single IP address or an inclusive range of IP addresses.
# If to is nil then the from field will be used to specify a single IP Address only.
#
class IPRange
# Start of range *Required
attr_accessor :from
# End of range *Optional (If nil then IPRange is a single IP Address)
attr_accessor :to
# @overload initialize(ip)
# @param [#to_s] from the IP single IP address.
# @example
# Nexpose::IPRange.new('192.168.1.0')
#
# @overload initialize(start_ip, end_ip)
# @param [#to_s] from the IP to start the range with.
# @param [#to_s] to the IP to end the range with.
# @example
# Nexpose::IPRange.new('192.168.1.0', '192.168.1.255')
#
# @overload initialize(cidr_range)
# @param [#to_s] from the CIDR notation IP address range.
# @example
# Nexpose::IPRange.new('192.168.1.0/24')
# @note The range will not be stripped of reserved IP addresses (such as
# x.x.x.0 and x.x.x.255).
#
# @return [IPRange] an IP address range of one or more addresses.
def initialize(from, to = nil)
@from = from
@to = to unless from == to
return unless @to.nil?
range = IPAddr.new(@from.to_s).to_range
unless range.one?
@from = range.first.to_s
@to = range.last.to_s
end
end
# Size of the IP range. The total number of IP addresses represented
# by this range.
#
# @return [Fixnum] size of the range.
#
def size
return 1 if @to.nil?
from = IPAddr.new(@from)
to = IPAddr.new(@to)
(from..to).to_a.size
end
include Comparable
def <=>(other)
return 1 unless other.respond_to? :from
from = IPAddr.new(@from)
to = @to.nil? ? from : IPAddr.new(@to)
cf_from = IPAddr.new(other.from)
cf_to = IPAddr.new(other.to.nil? ? other.from : other.to)
if cf_to < from
1
elsif to < cf_from
-1
else # Overlapping
0
end
end
def ==(other)
eql?(other)
end
def eql?(other)
return false unless other.respond_to? :from
@from == other.from && @to == other.to
end
def include?(single_ip)
return false unless single_ip.respond_to? :from
from = IPAddr.new(@from)
to = @to.nil? ? from : IPAddr.new(@to)
other = IPAddr.new(single_ip)
if other < from
false
elsif to < other
false
else
true
end
end
def hash
to_xml.hash
end
def as_xml
xml = REXML::Element.new('range')
xml.add_attributes({ 'from' => @from, 'to' => @to })
xml
end
alias to_xml_elem as_xml
def to_xml
as_xml.to_s
end
def to_s
return from.to_s if to.nil?
"#{from.to_s} - #{to.to_s}"
end
end
end