# frozen_string_literal: true require_relative "./resource_helper" require_relative "./weakness" require_relative "./activity" module HackerOne module Client class Report include ResourceHelper STATES = %w( new triaged needs-more-info resolved not-applicable informative duplicate spam ).map(&:to_sym).freeze STATES_REQUIRING_STATE_CHANGE_MESSAGE = %w( needs-more-info informative duplicate ).map(&:to_sym).freeze RESOLVED_STATES = %w( resolved not-applicable informative duplicate spam ).map(&:to_sym).freeze SEVERITY_RATINGS = %w( none low medium high critical ).freeze class << self def add_on_state_change_hook(proc) on_state_change_hooks << proc end def clear_on_state_change_hooks @on_state_change_hooks = [] end def on_state_change_hooks @on_state_change_hooks ||= [] end end def initialize(report) @report = report end def id @report[:id] end def title attributes[:title] end def created_at attributes[:created_at] end def issue_tracker_reference_url attributes[:issue_tracker_reference_url] end def issue_tracker_reference_id attributes[:issue_tracker_reference_id] end def severity attributes[:severity] end def state attributes[:state] end def reporter relationships .fetch(:reporter, {}) .fetch(:data, {}) .fetch(:attributes, {}) end def assignee if assignee_relationship = relationships[:assignee] HackerOne::Client::User.new(assignee_relationship[:data]) else nil end end def payment_total payments.reduce(0) { |total, payment| total + payment_amount(payment) } end def structured_scope StructuredScope.new(relationships[:structured_scope].fetch(:data, {}), program) end # Excludes reports where the payout amount is 0 indicating swag-only or no # payout for the issue supplied def risk case payment_total when HackerOne::Client.low_range || DEFAULT_LOW_RANGE "low" when HackerOne::Client.medium_range || DEFAULT_MEDIUM_RANGE "medium" when HackerOne::Client.high_range || DEFAULT_HIGH_RANGE "high" when HackerOne::Client.critical_range || DEFAULT_CRITICAL_RANGE "critical" end end def summary attributes[:vulnerability_information] end def weakness @weakness ||= Weakness.new(relationships.fetch(:weakness, {}).fetch(:data, {}).fetch(:attributes, {})) end def classification_label weakness.to_owasp end # Bounty writeups just use the key, and not the label value. def writeup_classification classification_label.split("-").first end def attachments @attachments ||= relationships.fetch(:attachments, {}) .fetch(:data, []) .map { |attachment| HackerOne::Client::Attachment.new(attachment) } end def activities if ships = relationships.fetch(:activities, {}).fetch(:data, []) ships.map do |activity_data| Activities.build(activity_data) end end end def program @program || Program.find(relationships[:program][:data][:attributes][:handle]) end def award_bounty(message:, amount:, bonus_amount: nil) request_body = { message: message, amount: amount, bonus_amount: bonus_amount } response_body = make_post_request( "reports/#{id}/bounties", request_body: request_body ) Bounty.new(response_body) end def award_swag(message:) request_body = { message: message } response_body = make_post_request( "reports/#{id}/swags", request_body: request_body ) Swag.new(response_body, program) end def update_severity(rating:) raise ArgumentError, "Invalid severity rating" unless SEVERITY_RATINGS.include?(rating.to_s) request_body = { type: "severity", attributes: { rating: rating } } response_body = make_post_request( "reports/#{id}/severities", request_body: request_body ) @report[:attributes][:severity] = { rating: rating } Activities.build(response_body) end def suggest_bounty(message:, amount:, bonus_amount: nil) request_body = { message: message, amount: amount, bonus_amount: bonus_amount } response_body = make_post_request( "reports/#{id}/bounty_suggestions", request_body: request_body ) Activities.build(response_body) end ## Idempotent: change the state of a report. See STATES for valid values. # # id: the ID of the report # state: the state in which the report is to be put in # # returns an HackerOne::Client::Report object or raises an error if # no report is found. def state_change(state, message = nil, attributes = {}) raise ArgumentError, "state (#{state}) must be one of #{STATES}" unless STATES.include?(state) old_state = self.state body = { type: "state-change", attributes: { state: state } } body[:attributes] = body[:attributes].reverse_merge(attributes) if message body[:attributes][:message] = message elsif STATES_REQUIRING_STATE_CHANGE_MESSAGE.include?(state) fail ArgumentError, "State #{state} requires a message. No message was supplied." else # message is in theory optional, but a value appears to be required. body[:attributes][:message] = "" end response_json = make_post_request("reports/#{id}/state_changes", request_body: body) @report = response_json self.class.on_state_change_hooks.each do |hook| hook.call(self, old_state.to_s, state.to_s) end self end ## Idempotent: Add a report reference to a project # # id: the ID of the report # state: value for the reference (e.g. issue number or relative path to cross-repo issue) # # returns an HackerOne::Client::Report object or raises an error if # no report is found. def add_report_reference(reference) body = { type: "issue-tracker-reference-id", attributes: { reference: reference } } response_json = make_post_request("reports/#{id}/issue_tracker_reference_id", request_body: body) @report = response_json[:relationships][:report][:data] self end ## Idempotent: add the issue reference and put the report into the "triage" state. # # id: the ID of the report # state: value for the reference (e.g. issue number or relative path to cross-repo issue) # # returns an HackerOne::Client::Report object or raises an error if # no report is found. def triage(reference) add_report_reference(reference) state_change(:triaged) end # Add a comment to a report. By default, internal comments will be added. # # id: the ID of the report # message: the content of the comment that will be created # internal: "team only" comment (true, default) or "all participants" def add_comment(message, internal: true) fail ArgumentError, "message is required" if message.blank? body = { type: "activity-comment", attributes: { message: message, internal: internal } } response_json = make_post_request("reports/#{id}/activities", request_body: body) HackerOne::Client::Activities.build(response_json) end def lock! unless RESOLVED_STATES.include? self.state.to_sym raise ArgumentError, "Report must be closed before locking" end body = { type: "activity-comments-closed" } response_json = make_put_request("reports/#{id}/close_comments", request_body: body) HackerOne::Client::Activities.build(response_json) end def assign_to_user(name) member = program.find_member(name) _assign_to(member.user.id, :user) end def assign_to_group(name) group = program.find_group(name) _assign_to(group.id, :group) end def unassign _assign_to(nil, :nobody) end private def payments activities.select { |activity| activity.is_a? Activities::BountyAwarded } end def payment_amount(payment) payment.bounty_amount end def attributes @report[:attributes] end def relationships @report[:relationships] end def vulnerability_types relationships.fetch(:vulnerability_types, {}).fetch(:data, []) end def _assign_to(assignee_id, assignee_type) request_body = { type: assignee_type, } request_body[:id] = assignee_id if assignee_id response = HackerOne::Client::Api.hackerone_api_connection.put do |req| req.headers["Content-Type"] = "application/json" req.url "reports/#{id}/assignee" req.body = { data: request_body }.to_json end unless response.success? fail("Unable to assign report #{id} to #{assignee_type} with id '#{assignee_id}'. Response status: #{response.status}, body: #{response.body}") end @report = parse_response response end end end end