diff --git a/lib/nexpose.rb b/lib/nexpose.rb
index b08dcc94..94f64669 100644
--- a/lib/nexpose.rb
+++ b/lib/nexpose.rb
@@ -81,6 +81,8 @@
require 'nexpose/silo'
require 'nexpose/silo_profile'
require 'nexpose/site'
+require 'nexpose/tag'
+require 'nexpose/tag/criteria'
require 'nexpose/ticket'
require 'nexpose/user'
require 'nexpose/vuln'
diff --git a/lib/nexpose/ajax.rb b/lib/nexpose/ajax.rb
index c4fc3f8c..8bd616f4 100644
--- a/lib/nexpose/ajax.rb
+++ b/lib/nexpose/ajax.rb
@@ -10,6 +10,12 @@ module Nexpose
module AJAX
module_function
+ module CONTENT_TYPE
+ XML = 'text/xml; charset=UTF-8'
+ JSON = 'application/json; charset-utf-8'
+ FORM = 'application/x-www-form-urlencoded; charset=UTF-8'
+ end
+
# GET call to a Nexpose controller.
#
# @param [Connection] nsc API connection to a Nexpose console.
@@ -17,7 +23,7 @@ module AJAX
# @param [String] content_type Content type to use when issuing the GET.
# @return [String|REXML::Document|Hash] The response from the call.
#
- def get(nsc, uri, content_type = 'text/xml; charset=UTF-8')
+ def get(nsc, uri, content_type = CONTENT_TYPE::XML)
get = Net::HTTP::Get.new(uri)
get.set_content_type(content_type)
_request(nsc, get)
@@ -31,7 +37,7 @@ def get(nsc, uri, content_type = 'text/xml; charset=UTF-8')
# @param [String] content_type Content type to use when issuing the PUT.
# @return [String] The response from the call.
#
- def put(nsc, uri, payload = nil, content_type = 'text/xml; charset=UTF-8')
+ def put(nsc, uri, payload = nil, content_type = CONTENT_TYPE::XML)
put = Net::HTTP::Put.new(uri)
put.set_content_type(content_type)
put.body = payload.to_s if payload
@@ -46,13 +52,28 @@ def put(nsc, uri, payload = nil, content_type = 'text/xml; charset=UTF-8')
# @param [String] content_type Content type to use when issuing the POST.
# @return [String|REXML::Document|Hash] The response from the call.
#
- def post(nsc, uri, payload = nil, content_type = 'text/xml')
+ def post(nsc, uri, payload = nil, content_type = CONTENT_TYPE::XML)
post = Net::HTTP::Post.new(uri)
post.set_content_type(content_type)
post.body = payload.to_s if payload
_request(nsc, post)
end
+ # PATCH call to a Nexpose controller.
+ #
+ # @param [Connection] nsc API connection to a Nexpose console.
+ # @param [String] uri Controller address relative to https://host:port
+ # @param [String|REXML::Document] payload XML document required by the call.
+ # @param [String] content_type Content type to use when issuing the PATCH.
+ # @return [String] The response from the call.
+ #
+ def patch(nsc, uri, payload = nil, content_type = CONTENT_TYPE::XML)
+ patch = Net::HTTP::Patch.new(uri)
+ patch.set_content_type(content_type)
+ patch.body = payload.to_s if payload
+ _request(nsc, patch)
+ end
+
# POST call to a Nexpose controller that uses a form-post model.
# This is here to support legacy use of POST in old controllers.
#
@@ -63,7 +84,7 @@ def post(nsc, uri, payload = nil, content_type = 'text/xml')
# @param [String] content_type Content type to use when issuing the POST.
# @return [Hash] The parsed JSON response from the call.
#
- def form_post(nsc, uri, parameters, content_type = 'application/x-www-form-urlencoded; charset=UTF-8')
+ def form_post(nsc, uri, parameters, content_type = CONTENT_TYPE::FORM)
post = Net::HTTP::Post.new(uri)
post.set_content_type(content_type)
post.set_form_data(parameters)
@@ -75,7 +96,7 @@ def form_post(nsc, uri, parameters, content_type = 'application/x-www-form-urlen
# @param [Connection] nsc API connection to a Nexpose console.
# @param [String] uri Controller address relative to https://host:port
# @param [String] content_type Content type to use when issuing the DELETE.
- def delete(nsc, uri, content_type = 'text/xml')
+ def delete(nsc, uri, content_type = CONTENT_TYPE::XML)
delete = Net::HTTP::Delete.new(uri)
delete.set_content_type(content_type)
_request(nsc, delete)
diff --git a/lib/nexpose/dag.rb b/lib/nexpose/dag.rb
index ccb1c6be..4ce5c56d 100644
--- a/lib/nexpose/dag.rb
+++ b/lib/nexpose/dag.rb
@@ -33,7 +33,7 @@ def save(nsc)
@users.reject! { |id| admins.member? id }
params = @id ? { 'entityid' => @id, 'mode' => 'edit' } : { 'entityid' => false, 'mode' => false }
uri = AJAX.parametrize_uri('/data/assetGroup/saveAssetGroup', params)
- data = JSON.parse(AJAX.post(nsc, uri, _to_entity_details, 'application/json; charset-utf-8'))
+ data = JSON.parse(AJAX.post(nsc, uri, _to_entity_details, AJAX::CONTENT_TYPE::JSON))
data['response'] == 'success.'
end
diff --git a/lib/nexpose/filter.rb b/lib/nexpose/filter.rb
index 16e734dc..af96a2e8 100644
--- a/lib/nexpose/filter.rb
+++ b/lib/nexpose/filter.rb
@@ -134,6 +134,19 @@ module Field
# Valid Operators: CONTAINS, NOT_CONTAINS
SOFTWARE = 'SOFTWARE'
+ # Valid Operators: IS, IS_NOT, GREATER_THAN, LESS_THAN, IS_APPLIED, IS_NOT_APPLIED
+ # Valid Values: VERY_HIGH, HIGH, NORMAL, LOW, VERY_LOW
+ USER_ADDED_CRITICALITY_LEVEL = 'TAG_CRITICALITY'
+
+ # Valid Operators: IS, IS_NOT, STARTS_WITH, ENDS_WITH, IS_APPLIED, IS_NOT_APPLIED, CONTAINS, NOT_CONTAINS
+ USER_ADDED_CUSTOM_TAG = 'TAG'
+
+ # Valid Operators: IS, IS_NOT, STARTS_WITH, ENDS_WITH, IS_APPLIED, IS_NOT_APPLIED, CONTAINS, NOT_CONTAINS
+ USER_ADDED_TAG_LOCATION = 'TAG_LOCATION'
+
+ # Valid Operators: IS, IS_NOT, STARTS_WITH, ENDS_WITH, IS_APPLIED, IS_NOT_APPLIED, CONTAINS, NOT_CONTAINS
+ USER_ADDED_TAG_OWNER = 'TAG_OWNER'
+
# Valid Operators: ARE
# Valid Values: PRESENT, NOT_PRESENT
VALIDATED_VULNERABILITIES = 'VULNERABILITY_VALIDATED_STATUS'
@@ -171,6 +184,8 @@ module Operator
IS_NOT_EMPTY = 'IS_NOT_EMPTY'
INCLUDE = 'INCLUDE'
DO_NOT_INCLUDE = 'DO_NOT_INCLUDE'
+ IS_APPLIED = 'IS_APPLIED'
+ IS_NOT_APPLIED = 'IS_NOT_APPLIED'
end
# Specialized values used by certain search fields
diff --git a/lib/nexpose/group.rb b/lib/nexpose/group.rb
index f9e37a6a..2e27ba5d 100644
--- a/lib/nexpose/group.rb
+++ b/lib/nexpose/group.rb
@@ -63,7 +63,7 @@ def delete(connection)
class AssetGroup < AssetGroupSummary
include Sanitize
- attr_accessor :name, :description, :id
+ attr_accessor :name, :description, :id , :tags
# Array[Device] of devices associated with this asset group.
attr_accessor :assets
@@ -73,6 +73,7 @@ class AssetGroup < AssetGroupSummary
def initialize(name, desc, id = -1, risk = 0.0)
@name, @description, @id, @risk_score = name, desc, id, risk
@assets = []
+ @tags = []
end
def save(connection)
@@ -98,6 +99,11 @@ def to_xml
xml << %()
end
xml << ''
+ xml << ''
+ @tags.each do |tag|
+ xml << tag.as_xml.to_s
+ end
+ xml << ''
xml << ''
end
@@ -133,7 +139,6 @@ def self.load(connection, id)
def self.parse(xml)
return nil unless xml
-
group = REXML::XPath.first(xml, 'AssetGroupConfigResponse/AssetGroup')
asset_group = new(group.attributes['name'],
group.attributes['description'],
@@ -146,6 +151,9 @@ def self.parse(xml)
dev.attributes['riskfactor'].to_f,
dev.attributes['riskscore'].to_f)
end
+ group.elements.each('Tags/Tag') do |tag|
+ asset_group.tags << TagSummary.parse_xml(tag)
+ end
asset_group
end
end
diff --git a/lib/nexpose/site.rb b/lib/nexpose/site.rb
index 26029670..f811c31e 100644
--- a/lib/nexpose/site.rb
+++ b/lib/nexpose/site.rb
@@ -128,6 +128,9 @@ class Site
# Modifying their behavior through the API is not recommended.
attr_accessor :is_dynamic
+ # [Array[TagSummary]] Collection of TagSummary
+ attr_accessor :tags
+
# Site constructor. Both arguments are optional.
#
# @param [String] name Unique name of the site.
@@ -146,6 +149,7 @@ def initialize(name = nil, scan_template = 'full-audit')
@alerts = []
@exclude = []
@users = []
+ @tags = []
end
# Returns true when the site is dynamic.
@@ -309,6 +313,11 @@ def as_xml
elem.add_element(sched)
xml.add_element(elem)
+ unless tags.empty?
+ tag_xml = xml.add_element(REXML::Element.new('Tags'))
+ @tags.each { |tag| tag_xml.add_element(tag.as_xml) }
+ end
+
xml
end
@@ -370,6 +379,10 @@ def self.parse(rexml)
site.alerts << Alert.parse(alert)
end
+ s.elements.each('Tags/Tag') do |tag|
+ site.tags << TagSummary.parse_xml(tag)
+ end
+
return site
end
nil
diff --git a/lib/nexpose/tag.rb b/lib/nexpose/tag.rb
new file mode 100644
index 00000000..2fe93c42
--- /dev/null
+++ b/lib/nexpose/tag.rb
@@ -0,0 +1,345 @@
+module Nexpose
+ module_function
+
+ class Connection
+ # Lists all tags
+ #
+ # @return [Array[TagSummary]] List of current tags.
+ #
+ def list_tags
+ tag_summary = []
+ tags = JSON.parse(AJAX.get(self, '/api/2.0/tags'))
+ tags['resources'].each do |json|
+ tag_summary << TagSummary.parse(json)
+ end
+ tag_summary
+ end
+ alias_method :tags, :list_tags
+
+ # Deletes a tag by ID
+ #
+ # @param [Fixnum] tag_id ID of tag to delete
+ #
+ def delete_tag(tag_id)
+ AJAX.delete(self, "/api/2.0/tags/#{tag_id}")
+ end
+
+ # Lists all the tags on an asset
+ #
+ # @param [Fixnum] asset_id of the asset to list the applied tags for
+ # @return [Array[TagSummary]] list of tags on asset
+ #
+ def list_asset_tags(asset_id)
+ tag_summary = []
+ asset_tag = JSON.parse(AJAX.get(self, "/api/2.0/assets/#{asset_id}/tags"))
+ asset_tag['resources'].select { |r| r['asset_ids'].find { |i| i == asset_id } }.each do |json|
+ tag_summary << TagSummary.parse(json)
+ end
+ tag_summary
+ end
+ alias_method :asset_tags, :list_asset_tags
+
+ # Removes a tag from an asset
+ #
+ # @param [Fixnum] asset_id on which to remove tag
+ # @param [Fixnum] tag_id to remove from asset
+ #
+ def remove_tag_from_asset(asset_id, tag_id)
+ AJAX.delete(self, "/api/2.0/assets/#{asset_id}/tags/#{tag_id}")
+ end
+
+ # Lists all the tags on a site
+ #
+ # @param [Fixnum] site_id id of the site to get the applied tags
+ # @return [Array[TagSummary]] list of tags on site
+ #
+ def list_site_tags(site_id)
+ tag_summary = []
+ site_tag = JSON.parse(AJAX.get(self, "/api/2.0/sites/#{site_id}/tags"))
+ site_tag['resources'].each do |json|
+ tag_summary << TagSummary.parse(json)
+ end
+ tag_summary
+ end
+
+ # Removes a tag from a site
+ #
+ # @param [Fixnum] site_id id of the site on which to remove the tag
+ # @param [Fixnum] tag_id id of the tag to remove
+ #
+ def remove_tag_from_site(site_id, tag_id)
+ AJAX.delete(self, "/api/2.0/sites/#{site_id}/tags/#{tag_id}")
+ end
+
+ # Lists all the tags on an asset_group
+ #
+ # @param [Fixnum] asset_group_id id of the group on which tags are listed
+ # @return [Array[TagSummary]] list of tags on asset group
+ #
+ def list_asset_group_tags(asset_group_id)
+ tag_summary = []
+ asset_group_tag = JSON.parse(AJAX.get(self, "/api/2.0/asset_groups/#{asset_group_id}/tags"))
+ asset_group_tag['resources'].each do |json|
+ tag_summary << TagSummary.parse(json)
+ end
+ tag_summary
+ end
+ alias_method :group_tags, :list_asset_group_tags
+ alias_method :asset_group_tags, :list_asset_group_tags
+
+ # Removes a tag from an asset_group
+ #
+ # @param [Fixnum] asset_group_id id of group on which to remove tag
+ # @param [Fixnum] tag_id of the tag to remove from asset group
+ #
+ def remove_tag_from_asset_group(asset_group_id, tag_id)
+ AJAX.delete(self, "/api/2.0/asset_groups/#{asset_group_id}/tags/#{tag_id}")
+ end
+ alias_method :remove_tag_from_group, :remove_tag_from_asset_group
+
+ # Returns the criticality value which takes precedent for an asset
+ #
+ # @param [Fixnum] asset_id id of asset on which criticality tag is selected
+ # @return [String] selected_criticality string of the relevant criticality; nil if not tagged
+ #
+ def selected_criticality_tag(asset_id)
+ selected_criticality = AJAX.get(self, "/data/asset/#{asset_id}/selected-criticality-tag")
+ selected_criticality.empty? ? nil : JSON.parse(selected_criticality)['name']
+ end
+ end
+
+ # Summary value object for tag information
+ #
+ class TagSummary
+
+ # ID of tag
+ attr_accessor :id
+
+ # Name of tag
+ attr_accessor :name
+
+ # One of Tag::Type::Generic
+ attr_accessor :type
+
+ def initialize(name, type, id)
+ @name, @type, @id = name, type, id
+ end
+
+ def self.parse(json)
+ new(json['tag_name'], json['tag_type'], json['tag_id'])
+ end
+
+ def self.parse_xml(xml)
+ new(xml.attributes['name'], xml.attributes['type'], xml.attributes['id'].to_i)
+ end
+
+ # XML representation of the tag summary as required by Site and AssetGroup
+ #
+ # @return [ELEMENT] XML element
+
+ def as_xml
+ xml = REXML::Element.new('Tag')
+ xml.add_attribute('id', @id)
+ xml.add_attribute('name', @name)
+ xml.add_attribute('type', @type)
+ xml
+ end
+ end
+
+ # Tag object containing tag details
+ #
+ class Tag < TagSummary
+ module Type
+ # Criticality tag types
+ module Level
+ VERY_HIGH = 'Very High'
+ HIGH = 'High'
+ MEDIUM = 'Medium'
+ LOW = 'Low'
+ VERY_LOW = 'Very Low'
+ end
+
+ # Tag types
+ module Generic
+ GENERAL = 'GENERAL'
+ OWNER = 'OWNER'
+ LOCATION = 'LOCATION'
+ CRITICALITY = 'CRITICALITY'
+ end
+ end
+
+ DEFAULT_COLOR = '#F6F6F6'
+
+ # Creation source
+ attr_accessor :source
+
+ # HEX color code of tag
+ attr_accessor :color
+
+ # Risk modifier
+ attr_accessor :risk_modifier
+
+ # Array containing Site IDs to be associated with tag
+ attr_accessor :site_ids
+
+ # Array containing Asset IDs to be associated with tag
+ attr_accessor :asset_ids
+
+ # Array containing Asset IDs directly associated with the tag
+ attr_accessor :associated_asset_ids
+
+ # Array containing Asset Group IDs to be associated with tag
+ attr_accessor :asset_group_ids
+ alias_method :group_ids, :asset_group_ids
+ alias_method :group_ids=, :asset_group_ids=
+
+ # A TagCriteria
+ attr_accessor :search_criteria
+
+ def initialize(name, type, id = -1)
+ @name, @type, @id = name, type, id
+ @source = 'nexpose-client'
+ @color = @type == Type::Generic::GENERAL ? DEFAULT_COLOR : nil
+ end
+
+ # Creates and saves a tag to Nexpose console
+ #
+ # @param [Connection] connection Nexpose connection
+ # @return [Fixnum] ID of saved tag
+ #
+ def save(connection)
+ params = to_json
+ if @id == -1
+ uri = AJAX.post(connection, '/api/2.0/tags', params, AJAX::CONTENT_TYPE::JSON)
+ @id = uri.split('/').last.to_i
+ else
+ AJAX.put(connection, "/api/2.0/tags/#{@id}", params, AJAX::CONTENT_TYPE::JSON)
+ end
+ @id
+ end
+
+ # Retrieve detailed description of a single tag
+ #
+ # @param [Connection] connection Nexpose connection
+ # @param [Fixnum] ID of tag to retrieve
+ # @return [Tag] requested tag
+ #
+ def self.load(connection, tag_id)
+ json = JSON.parse(AJAX.get(connection, "/api/2.0/tags/#{tag_id}"))
+ Tag.parse(json)
+ end
+
+ def to_json
+ json = {
+ 'tag_name' => @name,
+ 'tag_type' => @type,
+ 'tag_id' => @id,
+ 'attributes' => [
+ { 'tag_attribute_name' => 'SOURCE',
+ 'tag_attribute_value' => @source }
+ ],
+ 'tag_config' => { 'site_ids' => @site_ids,
+ 'tag_associated_asset_ids' => @asset_ids,
+ 'asset_group_ids' => @asset_group_ids,
+ 'search_criteria' => @search_criteria ? @search_criteria.to_map : nil
+ }
+ }
+ if @type == Type::Generic::GENERAL
+ json['attributes'] << { 'tag_attribute_name' => 'COLOR', 'tag_attribute_value' => @color }
+ elsif @type == Type::Generic::CRITICALITY
+ json['attributes'] << { 'tag_attribute_name' => 'RISK_MODIFIER', 'tag_attribute_value' => 5.0 }
+ end
+ JSON.generate(json)
+ end
+
+ # Delete this tag from Nexpose console
+ #
+ # @param [Connection] connection Nexpose connection
+ #
+ def delete(connection)
+ connection.delete_tag(@id)
+ end
+
+ def self.parse(json)
+ color = json['attributes'].find { |attr| attr['tag_attribute_name'] == 'COLOR' }
+ color = color['tag_attribute_value'] if color
+ source = json['attributes'].find { |attr| attr['tag_attribute_name'] == 'SOURCE' }
+ source = source['tag_attribute_value'] if source
+ tag = Tag.new(json['tag_name'], json['tag_type'], json['tag_id'])
+ tag.color = color
+ tag.source = source
+ tag.asset_ids = json['asset_ids']
+ if json['tag_config']
+ tag.site_ids = json['tag_config']['site_ids']
+ tag.associated_asset_ids = json['tag_config']['tag_associated_asset_ids']
+ tag.asset_group_ids = json['tag_config']['asset_group_ids']
+ criteria = json['tag_config']['search_criteria']
+ tag.search_criteria = criteria ? Criteria.parse(criteria) : nil
+ end
+ modifier = json['attributes'].find { |attr| attr['tag_attribute_name'] == 'RISK_MODIFIER' }
+ if modifier
+ tag.risk_modifier = modifier['tag_attribute_value'].to_i
+ end
+ tag
+ end
+
+ # Adds a tag to an asset
+ #
+ # @param [Connection] connection Nexpose connection
+ # @param [Fixnum] asset_id of the asset to be tagged
+ # @return [Fixnum] ID of applied tag
+ #
+ def add_to_asset(connection, asset_id)
+ params = to_json_for_add
+ uri = AJAX.post(connection, "/api/2.0/assets/#{asset_id}/tags", params, AJAX::CONTENT_TYPE::JSON)
+ @id = uri.split('/').last.to_i
+ end
+
+ # Adds a tag to a site
+ #
+ # @param [Connection] connection Nexpose connection
+ # @param [Fixnum] site_id of the site to be tagged
+ # @return [Fixnum] ID of applied tag
+ #
+ def add_to_site(connection, site_id)
+ params = to_json_for_add
+ uri = AJAX.post(connection, "/api/2.0/sites/#{site_id}/tags", params, AJAX::CONTENT_TYPE::JSON)
+ @id = uri.split('/').last.to_i
+ end
+
+ # Adds a tag to an asset group
+ #
+ # @param [Connection] connection Nexpose connection
+ # @param [Fixnum] group_id id of the asset group to be tagged
+ # @return [Fixnum] ID of applied tag
+ #
+ def add_to_group(connection, group_id)
+ params = to_json_for_add
+ uri = AJAX.post(connection, "/api/2.0/asset_groups/#{group_id}/tags", params, AJAX::CONTENT_TYPE::JSON)
+ @id = uri.split('/').last.to_i
+ end
+ alias_method :add_to_asset_group, :add_to_group
+
+ private
+
+ def to_json_for_add
+ if @id == -1
+ json = {
+ 'tag_name' => @name,
+ 'tag_type' => @type,
+ 'attributes' => [
+ { 'tag_attribute_name' => 'SOURCE',
+ 'tag_attribute_value' => @source }
+ ],
+ }
+ if @type == Tag::Type::Generic::GENERAL
+ json['attributes'] << { 'tag_attribute_name' => 'COLOR', 'tag_attribute_value' => @color }
+ end
+ params = JSON.generate(json)
+ else
+ params = JSON.generate('tag_id' => @id)
+ end
+ params
+ end
+ end
+end
diff --git a/lib/nexpose/tag/criteria.rb b/lib/nexpose/tag/criteria.rb
new file mode 100644
index 00000000..b04bd266
--- /dev/null
+++ b/lib/nexpose/tag/criteria.rb
@@ -0,0 +1,45 @@
+module Nexpose
+ class Tag
+
+ class Criterion < Nexpose::Criterion
+
+ def to_map
+ { 'operator' => operator,
+ 'values' => Array(value),
+ 'field_name' => field
+ }
+ end
+
+ def self.parse(json)
+ Criterion.new(json['field_name'],
+ json['operator'],
+ json['values'])
+ end
+
+ end
+
+ class Criteria < Nexpose::Criteria
+
+ def initialize(criteria = [], match = 'AND')
+ super(criteria, match)
+ end
+
+ def to_map
+ { 'criteria' => @criteria.map { |c| c.to_map },
+ 'operator' => @match
+ }
+ end
+
+ def self.parse(json)
+ ret = Criteria.new([], json['operator'])
+ json['criteria'].each do |c|
+ ret.criteria << Criterion.parse(c)
+ end
+ ret
+ end
+
+ end
+ end
+end
+
+