hackerone-client 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +11 -0
- data/Guardfile +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +47 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/fixtures/vcr_cassettes/empty_report_list.yml +71 -0
- data/fixtures/vcr_cassettes/missing_report.yml +69 -0
- data/fixtures/vcr_cassettes/report.yml +464 -0
- data/fixtures/vcr_cassettes/report_list.yml +240 -0
- data/hackerone-client.gemspec +30 -0
- data/lib/hackerone/client.rb +113 -0
- data/lib/hackerone/client/report.rb +115 -0
- data/lib/hackerone/client/version.rb +5 -0
- metadata +167 -0
@@ -0,0 +1,240 @@
|
|
1
|
+
---
|
2
|
+
http_interactions:
|
3
|
+
- request:
|
4
|
+
method: get
|
5
|
+
uri: https://api.hackerone.com/v1/reports?filter%5Bcreated_at__gt%5D=2017-02-11T16:00:44-10:00&filter%5Bprogram%5D%5B0%5D=github&filter%5Bstate%5D%5B0%5D=new
|
6
|
+
body:
|
7
|
+
encoding: US-ASCII
|
8
|
+
string: ''
|
9
|
+
headers:
|
10
|
+
Authorization:
|
11
|
+
- Basic nope=
|
12
|
+
User-Agent:
|
13
|
+
- Faraday v0.11.0
|
14
|
+
Accept-Encoding:
|
15
|
+
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
16
|
+
Accept:
|
17
|
+
- "*/*"
|
18
|
+
response:
|
19
|
+
status:
|
20
|
+
code: 200
|
21
|
+
message: OK
|
22
|
+
headers:
|
23
|
+
Date:
|
24
|
+
- Sat, 18 Feb 2017 19:16:35 GMT
|
25
|
+
Content-Type:
|
26
|
+
- application/json; charset=utf-8
|
27
|
+
Transfer-Encoding:
|
28
|
+
- chunked
|
29
|
+
Connection:
|
30
|
+
- keep-alive
|
31
|
+
Set-Cookie:
|
32
|
+
- __cfduid=123; expires=Sun, 18-Feb-18
|
33
|
+
19:16:35 GMT; path=/; Domain=api.hackerone.com; HttpOnly
|
34
|
+
X-Request-Id:
|
35
|
+
- 123
|
36
|
+
Etag:
|
37
|
+
- W/"e337505dbc57f7e1f85685911c939b6e"
|
38
|
+
Cache-Control:
|
39
|
+
- max-age=0, private, must-revalidate
|
40
|
+
Strict-Transport-Security:
|
41
|
+
- max-age=31536000; includeSubDomains; preload
|
42
|
+
Content-Security-Policy:
|
43
|
+
- default-src 'none'; connect-src 'self' www.google-analytics.com errors.hackerone.net;
|
44
|
+
font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'
|
45
|
+
'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; report-uri https://errors.hackerone.net/api/30/csp-report/?sentry_key=61c1e2f50d21487c97a071737701f598
|
46
|
+
X-Content-Type-Options:
|
47
|
+
- nosniff
|
48
|
+
X-Download-Options:
|
49
|
+
- noopen
|
50
|
+
X-Frame-Options:
|
51
|
+
- DENY
|
52
|
+
X-Permitted-Cross-Domain-Policies:
|
53
|
+
- none
|
54
|
+
X-Xss-Protection:
|
55
|
+
- 1; mode=block
|
56
|
+
Public-Key-Pins-Report-Only:
|
57
|
+
- pin-sha256="WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18="; pin-sha256="r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=";
|
58
|
+
pin-sha256="K87oWBWM9UZfyddvDfoxL+8lpNyoUB2ptGtn0fv6G2Q="; pin-sha256="iie1VXtL7HzAMF+/PVPR9xzT80kQxdZeJ+zduCB3uj0=";
|
59
|
+
pin-sha256="cGuxAXyFXFkWm61cF4HPWX8S0srS9j0aSqN0k4AP+4A="; pin-sha256="bIlWcjiKq1mftH/xd7Hw1JO77Cr+Gv+XYcGUQWwO+A4=";
|
60
|
+
pin-sha256="tXD+dGAP8rGY4PW1be90cOYEwg7pZ4G+yPZmIZWPTSg="; max-age=600; includeSubDomains;
|
61
|
+
report-uri="https://hackerone.report-uri.io/r/default/hpkp/reportOnly"
|
62
|
+
Server:
|
63
|
+
- cloudflare-nginx
|
64
|
+
Cf-Ray:
|
65
|
+
- 3333d0794c920d4f-LAX
|
66
|
+
body:
|
67
|
+
encoding: ASCII-8BIT
|
68
|
+
string: '{
|
69
|
+
"data": [
|
70
|
+
{
|
71
|
+
"id": "207385",
|
72
|
+
"type": "report",
|
73
|
+
"attributes": {
|
74
|
+
"title": "What is my purpose",
|
75
|
+
"state": "new",
|
76
|
+
"created_at": "2017-02-18T18:26:13.283Z",
|
77
|
+
"vulnerability_information": "You pass the butter",
|
78
|
+
"triaged_at": null,
|
79
|
+
"closed_at": null,
|
80
|
+
"last_reporter_activity_at": "2017-02-18T18:26:13.387Z",
|
81
|
+
"first_program_activity_at": null,
|
82
|
+
"last_program_activity_at": null,
|
83
|
+
"bounty_awarded_at": null,
|
84
|
+
"swag_awarded_at": null,
|
85
|
+
"disclosed_at": null,
|
86
|
+
"last_activity_at": "2017-02-18T18:26:13.387Z"
|
87
|
+
},
|
88
|
+
"relationships": {
|
89
|
+
"reporter": {
|
90
|
+
"data": {
|
91
|
+
"id": "123",
|
92
|
+
"type": "user",
|
93
|
+
"attributes": {
|
94
|
+
"username": "rickestofallthericks",
|
95
|
+
"name": "Rick Sanchez",
|
96
|
+
"disabled": false,
|
97
|
+
"created_at": "2017-02-18T18:21:28.638Z",
|
98
|
+
"profile_picture": {
|
99
|
+
"62x62": "/assets/avatars/default-123.png",
|
100
|
+
"82x82": "/assets/avatars/default-123.png",
|
101
|
+
"110x110": "/assets/avatars/default-123.png",
|
102
|
+
"260x260": "/assets/avatars/default-123.png"
|
103
|
+
}
|
104
|
+
}
|
105
|
+
}
|
106
|
+
},
|
107
|
+
"program": {
|
108
|
+
"data": {
|
109
|
+
"id": "1894",
|
110
|
+
"type": "program",
|
111
|
+
"attributes": {
|
112
|
+
"handle": "github",
|
113
|
+
"created_at": "2015-05-29T20:12:03.091Z",
|
114
|
+
"updated_at": "2017-02-17T15:18:04.114Z"
|
115
|
+
}
|
116
|
+
}
|
117
|
+
},
|
118
|
+
"severity": {
|
119
|
+
"data": {
|
120
|
+
"id": "26662",
|
121
|
+
"type": "severity",
|
122
|
+
"attributes": {
|
123
|
+
"rating": "critical",
|
124
|
+
"author_type": "User",
|
125
|
+
"user_id": 123,
|
126
|
+
"created_at": "2017-02-18T18:26:13.373Z",
|
127
|
+
"score": 9.1,
|
128
|
+
"attack_complexity": "low",
|
129
|
+
"attack_vector": "network",
|
130
|
+
"availability": "none",
|
131
|
+
"confidentiality": "high",
|
132
|
+
"integrity": "high",
|
133
|
+
"privileges_required": "none",
|
134
|
+
"user_interaction": "none",
|
135
|
+
"scope": "unchanged"
|
136
|
+
}
|
137
|
+
}
|
138
|
+
},
|
139
|
+
"vulnerability_types": {
|
140
|
+
"data": [
|
141
|
+
{
|
142
|
+
"id": "25110",
|
143
|
+
"type": "vulnerability-type",
|
144
|
+
"attributes": {
|
145
|
+
"name": "Information Disclosure",
|
146
|
+
"description": "nope",
|
147
|
+
"created_at": "2016-01-28T13:34:08.945Z"
|
148
|
+
}
|
149
|
+
}
|
150
|
+
]
|
151
|
+
},
|
152
|
+
"bounties": {
|
153
|
+
"data": []
|
154
|
+
}
|
155
|
+
}
|
156
|
+
},
|
157
|
+
{
|
158
|
+
"id": "207228",
|
159
|
+
"type": "report",
|
160
|
+
"attributes": {
|
161
|
+
"title": "Take 2 Strokes off of Jerry''s swing",
|
162
|
+
"state": "new",
|
163
|
+
"created_at": "2017-02-17T21:51:00.916Z",
|
164
|
+
"vulnerability_information": "Hai",
|
165
|
+
"triaged_at": null,
|
166
|
+
"closed_at": null,
|
167
|
+
"last_reporter_activity_at": "2017-02-17T21:51:01.110Z",
|
168
|
+
"first_program_activity_at": null,
|
169
|
+
"last_program_activity_at": null,
|
170
|
+
"bounty_awarded_at": null,
|
171
|
+
"swag_awarded_at": null,
|
172
|
+
"disclosed_at": null,
|
173
|
+
"last_activity_at": "2017-02-17T21:51:01.110Z"
|
174
|
+
},
|
175
|
+
"relationships": {
|
176
|
+
"reporter": {
|
177
|
+
"data": {
|
178
|
+
"id": "1234",
|
179
|
+
"type": "user",
|
180
|
+
"attributes": {
|
181
|
+
"username": "ImMrMeeseeks",
|
182
|
+
"name": "Mr Meeseeks",
|
183
|
+
"disabled": false,
|
184
|
+
"created_at": "2017-02-17T20:52:56.666Z",
|
185
|
+
"profile_picture": {
|
186
|
+
"62x62": "/assets/avatars/default-123.png",
|
187
|
+
"82x82": "/assets/avatars/default-123.png",
|
188
|
+
"110x110": "/assets/avatars/default-123.png",
|
189
|
+
"260x260": "/assets/avatars/default-123.png"
|
190
|
+
}
|
191
|
+
}
|
192
|
+
}
|
193
|
+
},
|
194
|
+
"program": {
|
195
|
+
"data": {
|
196
|
+
"id": "1894",
|
197
|
+
"type": "program",
|
198
|
+
"attributes": {
|
199
|
+
"handle": "github",
|
200
|
+
"created_at": "2015-05-29T20:12:03.091Z",
|
201
|
+
"updated_at": "2017-02-17T15:18:04.114Z"
|
202
|
+
}
|
203
|
+
}
|
204
|
+
},
|
205
|
+
"severity": {
|
206
|
+
"data": {
|
207
|
+
"id": "26541",
|
208
|
+
"type": "severity",
|
209
|
+
"attributes": {
|
210
|
+
"rating": "low",
|
211
|
+
"author_type": "User",
|
212
|
+
"user_id": 144214,
|
213
|
+
"created_at": "2017-02-17T21:51:01.093Z"
|
214
|
+
}
|
215
|
+
}
|
216
|
+
},
|
217
|
+
"vulnerability_types": {
|
218
|
+
"data": [
|
219
|
+
{
|
220
|
+
"id": "25110",
|
221
|
+
"type": "vulnerability-type",
|
222
|
+
"attributes": {
|
223
|
+
"name": "Information Disclosure",
|
224
|
+
"description": "Exposure of system information, sensitive or private information, fingerprinting, etc.\n",
|
225
|
+
"created_at": "2016-01-28T13:34:08.945Z"
|
226
|
+
}
|
227
|
+
}
|
228
|
+
]
|
229
|
+
},
|
230
|
+
"bounties": {
|
231
|
+
"data": []
|
232
|
+
}
|
233
|
+
}
|
234
|
+
}
|
235
|
+
],
|
236
|
+
"links": {}
|
237
|
+
}'
|
238
|
+
http_version:
|
239
|
+
recorded_at: Sat, 18 Feb 2017 19:16:35 GMT
|
240
|
+
recorded_with: VCR 3.0.3
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'hackerone/client/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "hackerone-client"
|
8
|
+
spec.version = Hackerone::Client::VERSION
|
9
|
+
spec.authors = ["Neil Matatall"]
|
10
|
+
spec.email = ["neil.matatall@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{A limited client for the HackerOne API}
|
13
|
+
spec.homepage = "https://github.com/oreoshake/hackerone-client"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
|
+
f.match(%r{^(test|spec|features)/})
|
18
|
+
end
|
19
|
+
spec.bindir = "exe"
|
20
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
|
+
spec.require_paths = ["lib"]
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.14"
|
24
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
25
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
26
|
+
spec.add_development_dependency "vcr", "~> 3.0"
|
27
|
+
spec.add_development_dependency "webmock", "~> 2.3"
|
28
|
+
spec.add_runtime_dependency "faraday"
|
29
|
+
spec.add_runtime_dependency 'activesupport', '~> 3.0', '> 3.0'
|
30
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require "faraday"
|
2
|
+
require 'active_support/time'
|
3
|
+
require_relative "client/version"
|
4
|
+
require_relative "client/report"
|
5
|
+
|
6
|
+
module HackerOne
|
7
|
+
module Client
|
8
|
+
class NotConfiguredError < StandardError; end
|
9
|
+
|
10
|
+
DEFAULT_LOW_RANGE = 1...999
|
11
|
+
DEFAULT_MEDIUM_RANGE = 1000...2499
|
12
|
+
DEFAULT_HIGH_RANGE = 2500...4999
|
13
|
+
DEFAULT_CRITICAL_RANGE = 5000...100_000_000
|
14
|
+
|
15
|
+
class << self
|
16
|
+
ATTRS = [:low_range, :medium_range, :high_range, :critical_range].freeze
|
17
|
+
attr_accessor :program
|
18
|
+
attr_reader *ATTRS
|
19
|
+
|
20
|
+
ATTRS.each do |attr|
|
21
|
+
define_method "#{attr}=" do |value|
|
22
|
+
raise ArgumentError, "value must be a range object" unless value.is_a?(Range)
|
23
|
+
instance_variable_set :"@#{attr}", value
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class Api
|
29
|
+
def initialize(program = nil)
|
30
|
+
@program = program
|
31
|
+
end
|
32
|
+
|
33
|
+
def program
|
34
|
+
@program || HackerOne::Client.program
|
35
|
+
end
|
36
|
+
|
37
|
+
## Returns all open reports, optionally with a time bound
|
38
|
+
#
|
39
|
+
# program: the HackerOne program to search on (configure globally with Hackerone::Client.program=)
|
40
|
+
# since (optional): a time bound, don't include reports earlier than +since+. Must be a DateTime object.
|
41
|
+
#
|
42
|
+
# returns all open reports or an empty array
|
43
|
+
def reports(since: 3.days.ago)
|
44
|
+
raise ArgumentError, "Program cannot be nil" unless program
|
45
|
+
response = self.class.hackerone_api_connection.get do |req|
|
46
|
+
options = {
|
47
|
+
"filter[state][]" => "new",
|
48
|
+
"filter[program][]" => program,
|
49
|
+
"filter[created_at__gt]" => since.iso8601
|
50
|
+
}
|
51
|
+
req.url "reports", options
|
52
|
+
end
|
53
|
+
|
54
|
+
data = JSON.parse(response.body, :symbolize_names => true)[:data]
|
55
|
+
if data.nil?
|
56
|
+
raise RuntimeError, "Expected data attribute in response: #{response.body}"
|
57
|
+
end
|
58
|
+
|
59
|
+
data.map do |report|
|
60
|
+
Report.new(report)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
## Public: retrieve a report
|
65
|
+
#
|
66
|
+
# id: the ID of a specific report
|
67
|
+
#
|
68
|
+
# returns an HackerOne::Client::Report object or raises an error if
|
69
|
+
# no report is found.
|
70
|
+
def report(id)
|
71
|
+
response = with_retry do
|
72
|
+
self.class.hackerone_api_connection.get do |req|
|
73
|
+
req.url "reports/#{id}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
if response.success?
|
78
|
+
Report.new(JSON.parse(response.body, :symbolize_names => true)[:data])
|
79
|
+
else
|
80
|
+
raise ArgumentError, "Could not retrieve HackerOne report ##{id}: #{response.body}"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
def self.hackerone_api_connection
|
86
|
+
unless ENV["HACKERONE_TOKEN_NAME"] && ENV["HACKERONE_TOKEN"]
|
87
|
+
raise NotConfiguredError, "HACKERONE_TOKEN_NAME HACKERONE_TOKEN environment variables must be set"
|
88
|
+
end
|
89
|
+
|
90
|
+
@connection ||= Faraday.new(:url => "https://api.hackerone.com/v1") do |faraday|
|
91
|
+
faraday.basic_auth(ENV["HACKERONE_TOKEN_NAME"], ENV["HACKERONE_TOKEN"])
|
92
|
+
faraday.adapter Faraday.default_adapter
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def with_retry(attempts=3, &block)
|
97
|
+
attempts_remaining = attempts
|
98
|
+
|
99
|
+
begin
|
100
|
+
yield
|
101
|
+
rescue StandardError
|
102
|
+
if attempts_remaining > 0
|
103
|
+
attempts_remaining -= 1
|
104
|
+
sleep (attempts - attempts_remaining)
|
105
|
+
retry
|
106
|
+
else
|
107
|
+
raise
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module HackerOne
|
2
|
+
module Client
|
3
|
+
class Report
|
4
|
+
PAYOUT_ACTIVITY_KEY = "activity-bounty-awarded"
|
5
|
+
CLASSIFICATION_MAPPING = {
|
6
|
+
"None Applicable" => "A0-Other",
|
7
|
+
"Denial of Service" => "A0-Other",
|
8
|
+
"Memory Corruption" => "A0-Other",
|
9
|
+
"Cryptographic Issue" => "A0-Other",
|
10
|
+
"Privilege Escalation" => "A0-Other",
|
11
|
+
"UI Redressing (Clickjacking)" => "A0-Other",
|
12
|
+
"Command Injection" => "A1-Injection",
|
13
|
+
"Remote Code Execution" => "A1-Injection",
|
14
|
+
"SQL Injection" => "A1-Injection",
|
15
|
+
"Authentication" => "A2-AuthSession",
|
16
|
+
"Cross-Site Scripting (XSS)" => "A3-XSS",
|
17
|
+
"Information Disclosure" => "A6-DataExposure",
|
18
|
+
"Cross-Site Request Forgery (CSRF)" => "A8-CSRF",
|
19
|
+
"Unvalidated / Open Redirect" => "A10-Redirects"
|
20
|
+
}
|
21
|
+
|
22
|
+
def initialize(report)
|
23
|
+
@report = report
|
24
|
+
end
|
25
|
+
|
26
|
+
def id
|
27
|
+
@report[:id]
|
28
|
+
end
|
29
|
+
|
30
|
+
def title
|
31
|
+
attributes[:title]
|
32
|
+
end
|
33
|
+
|
34
|
+
def vulnerability_information
|
35
|
+
attributes[:vulnerability_information]
|
36
|
+
end
|
37
|
+
|
38
|
+
def reporter
|
39
|
+
relationships
|
40
|
+
.fetch(:reporter, {})
|
41
|
+
.fetch(:data, {})
|
42
|
+
.fetch(:attributes, {})
|
43
|
+
end
|
44
|
+
|
45
|
+
def payment_total
|
46
|
+
payments.reduce(0) { |total, payment| total + payment_amount(payment) }
|
47
|
+
end
|
48
|
+
|
49
|
+
# Excludes reports where the payout amount is 0 indicating swag-only or no
|
50
|
+
# payout for the issue supplied
|
51
|
+
def risk
|
52
|
+
case payment_total
|
53
|
+
when HackerOne::Client.low_range || DEFAULT_LOW_RANGE
|
54
|
+
"low"
|
55
|
+
when HackerOne::Client.medium_range || DEFAULT_MEDIUM_RANGE
|
56
|
+
"medium"
|
57
|
+
when HackerOne::Client.high_range || DEFAULT_HIGH_RANGE
|
58
|
+
"high"
|
59
|
+
when HackerOne::Client.critical_range || DEFAULT_CRITICAL_RANGE
|
60
|
+
"critical"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def summary
|
65
|
+
summaries = relationships.fetch(:summaries, {}).fetch(:data, []).select {|summary| summary[:type] == "report-summary" }
|
66
|
+
return unless summaries
|
67
|
+
|
68
|
+
summaries.select { |summary| summary[:attributes][:category] == "team" }.map do |summary|
|
69
|
+
summary[:attributes][:content]
|
70
|
+
end.join("\n")
|
71
|
+
end
|
72
|
+
|
73
|
+
# Do our best to map the value that hackerone provides and the reporter sets
|
74
|
+
# to the OWASP Top 10. Take the first match since multiple values can be set.
|
75
|
+
# This is used for the issue label.
|
76
|
+
def classification_label
|
77
|
+
owasp_mapping = vulnerability_types.map do |vuln_type|
|
78
|
+
CLASSIFICATION_MAPPING[vuln_type[:attributes][:name]]
|
79
|
+
end.flatten.first
|
80
|
+
|
81
|
+
owasp_mapping || CLASSIFICATION_MAPPING["None Applicable"]
|
82
|
+
end
|
83
|
+
|
84
|
+
# Bounty writeups just use the key, and not the label value.
|
85
|
+
def writeup_classification
|
86
|
+
classification_label().split("-").first
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
def payments
|
91
|
+
activities.select { |activity| activity[:type] == PAYOUT_ACTIVITY_KEY }
|
92
|
+
end
|
93
|
+
|
94
|
+
def payment_amount(payment)
|
95
|
+
payment.fetch(:attributes, {}).fetch(:bounty_amount, 0).gsub(/[^\d]/, "").to_i
|
96
|
+
end
|
97
|
+
|
98
|
+
def activities
|
99
|
+
relationships.fetch(:activities, {}).fetch(:data, [])
|
100
|
+
end
|
101
|
+
|
102
|
+
def attributes
|
103
|
+
@report[:attributes]
|
104
|
+
end
|
105
|
+
|
106
|
+
def relationships
|
107
|
+
@report[:relationships]
|
108
|
+
end
|
109
|
+
|
110
|
+
def vulnerability_types
|
111
|
+
relationships.fetch(:vulnerability_types, {}).fetch(:data, [])
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|