sorbet-hibp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a0cdf85cd575436f967204205717c5aa3ab0fba4f40c462668d52fe45ba2f9f7
4
+ data.tar.gz: 3bc67bbbbc5e7bdf548565bd3a75555ac2463319f2b7df72d087450f04931575
5
+ SHA512:
6
+ metadata.gz: 91c94864621aae1bdf326280868269020bb4d654145ec487b050ba11f33c553ab8071995c625eb658094e76a64e06dd339a3ec0f573b71e361525a8982bca07a
7
+ data.tar.gz: 19a0d5de08648c463fe3131bc609fac8934ab740f981fb753d9da402b4c71bd92781ca07f7d96619221c59cb8907605b5f998b6db13e005710d8e146a7a7dbf7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sorbeet Payments OU
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # HaveIBeenPwned
2
+
3
+ A simple, clean Ruby wrapper for the Have I Been Pwned API v3.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'haveibeenpwned'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ gem install haveibeenpwned
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Initialize the Client
28
+
29
+ ```ruby
30
+ require 'haveibeenpwned'
31
+
32
+ client = HaveIBeenPwned::Client.new(
33
+ api_key: 'your-api-key-here',
34
+ user_agent: 'MyApp/1.0'
35
+ )
36
+ ```
37
+
38
+ Get your API key from [haveibeenpwned.com/API/Key](https://haveibeenpwned.com/API/Key).
39
+
40
+ ### Breach Lookups
41
+
42
+ #### Get all breaches for an account
43
+
44
+ ```ruby
45
+ breaches = client.breached_account('test@example.com')
46
+ # => [{"Name"=>"Adobe"}, {"Name"=>"LinkedIn"}]
47
+
48
+ # Get full breach data
49
+ breaches = client.breached_account('test@example.com', truncate_response: false)
50
+
51
+ # Filter by domain
52
+ breaches = client.breached_account('test@example.com', domain: 'adobe.com')
53
+
54
+ # Exclude unverified breaches
55
+ breaches = client.breached_account('test@example.com', include_unverified: false)
56
+ ```
57
+
58
+ #### Get all breached email addresses for a domain
59
+
60
+ ```ruby
61
+ # Requires domain verification in your HIBP dashboard
62
+ breached = client.breached_domain('example.com')
63
+ # => {"alias1"=>["Adobe"], "alias2"=>["Adobe", "LinkedIn"]}
64
+ ```
65
+
66
+ #### Get all breaches in the system
67
+
68
+ ```ruby
69
+ all_breaches = client.breaches
70
+ # => [{"Name"=>"Adobe", "Title"=>"Adobe", ...}, ...]
71
+
72
+ # Filter by domain
73
+ adobe_breaches = client.breaches(domain: 'adobe.com')
74
+
75
+ # Filter spam lists
76
+ spam_lists = client.breaches(is_spam_list: true)
77
+ ```
78
+
79
+ #### Get a single breach
80
+
81
+ ```ruby
82
+ breach = client.breach('Adobe')
83
+ # => {"Name"=>"Adobe", "Title"=>"Adobe", "Domain"=>"adobe.com", ...}
84
+ ```
85
+
86
+ #### Get the latest breach
87
+
88
+ ```ruby
89
+ latest = client.latest_breach
90
+ # => {"Name"=>"...", "AddedDate"=>"2025-01-15T10:30:00Z", ...}
91
+ ```
92
+
93
+ #### Get all data classes
94
+
95
+ ```ruby
96
+ classes = client.data_classes
97
+ # => ["Account balances", "Email addresses", "Passwords", ...]
98
+ ```
99
+
100
+ #### Get subscribed domains
101
+
102
+ ```ruby
103
+ domains = client.subscribed_domains
104
+ # => [{"DomainName"=>"example.com", "PwnCount"=>150, ...}]
105
+ ```
106
+
107
+ ### Stealer Logs
108
+
109
+ Requires Pwned 5+ subscription and verified domain ownership.
110
+
111
+ #### Get stealer log domains for an email
112
+
113
+ ```ruby
114
+ domains = client.stealer_logs_by_email('user@example.com')
115
+ # => ["netflix.com", "spotify.com"]
116
+ ```
117
+
118
+ #### Get email addresses for a website domain
119
+
120
+ ```ruby
121
+ emails = client.stealer_logs_by_website_domain('netflix.com')
122
+ # => ["user1@gmail.com", "user2@yahoo.com"]
123
+ ```
124
+
125
+ #### Get email aliases for an email domain
126
+
127
+ ```ruby
128
+ aliases = client.stealer_logs_by_email_domain('example.com')
129
+ # => {"user1"=>["netflix.com"], "user2"=>["spotify.com", "netflix.com"]}
130
+ ```
131
+
132
+ ### Pastes
133
+
134
+ ```ruby
135
+ pastes = client.pastes('test@example.com')
136
+ # => [{"Source"=>"Pastebin", "Id"=>"8Q0BvKD8", "Title"=>"syslog", ...}]
137
+ ```
138
+
139
+ ### Subscription Status
140
+
141
+ ```ruby
142
+ status = client.subscription_status
143
+ # => {"SubscriptionName"=>"Pwned 3", "Rpm"=>100, ...}
144
+ ```
145
+
146
+ ### Pwned Passwords
147
+
148
+ The Pwned Passwords API is completely separate and requires no authentication.
149
+
150
+ #### Check if a password has been pwned
151
+
152
+ ```ruby
153
+ count = HaveIBeenPwned::PwnedPasswords.check('password123')
154
+ # => 123456 (number of times this password appears in breaches)
155
+
156
+ # If password not found
157
+ count = HaveIBeenPwned::PwnedPasswords.check('very-unique-password-xyz')
158
+ # => 0
159
+ ```
160
+
161
+ #### Check with padding (enhanced privacy)
162
+
163
+ ```ruby
164
+ count = HaveIBeenPwned::PwnedPasswords.check('password123', padding: true)
165
+ ```
166
+
167
+ #### Check a pre-computed hash
168
+
169
+ ```ruby
170
+ hash = '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8' # SHA-1 of 'password'
171
+ count = HaveIBeenPwned::PwnedPasswords.check_hash(hash)
172
+ # => 123456
173
+ ```
174
+
175
+ #### Get raw range search results
176
+
177
+ ```ruby
178
+ # Get all hash suffixes for a prefix
179
+ results = HaveIBeenPwned::PwnedPasswords.range_search('5BAA6')
180
+ # => "1E4C9B93F3F0682250B6CF8331B7EE68FD8:123456\n..."
181
+ ```
182
+
183
+ ## Error Handling
184
+
185
+ The gem raises specific exceptions for different HTTP errors:
186
+
187
+ ```ruby
188
+ begin
189
+ breaches = client.breached_account('test@example.com')
190
+ rescue HaveIBeenPwned::NotFoundError
191
+ puts "Account not found in any breaches"
192
+ rescue HaveIBeenPwned::UnauthorizedError
193
+ puts "Invalid API key"
194
+ rescue HaveIBeenPwned::ForbiddenError
195
+ puts "Missing or invalid user agent"
196
+ rescue HaveIBeenPwned::RateLimitError => e
197
+ puts "Rate limit exceeded. Retry after #{e.retry_after} seconds"
198
+ rescue HaveIBeenPwned::BadRequestError => e
199
+ puts "Bad request: #{e.message}"
200
+ rescue HaveIBeenPwned::ServiceUnavailableError
201
+ puts "Service temporarily unavailable"
202
+ rescue HaveIBeenPwned::Error => e
203
+ puts "Unexpected error: #{e.message}"
204
+ end
205
+ ```
206
+
207
+ ## Test Accounts
208
+
209
+ Use test accounts on `hibp-integration-tests.com` domain with a test API key (any 32-char hex string like `00000000000000000000000000000000`):
210
+
211
+ ```ruby
212
+ client = HaveIBeenPwned::Client.new(
213
+ api_key: '00000000000000000000000000000000',
214
+ user_agent: 'Testing'
215
+ )
216
+
217
+ # Test account that exists in breaches
218
+ breaches = client.breached_account('account-exists@hibp-integration-tests.com')
219
+
220
+ # Test account with spam list only
221
+ spam = client.breached_account('spam-list-only@hibp-integration-tests.com')
222
+
223
+ # Test account with stealer logs
224
+ logs = client.breached_account('stealer-log@hibp-integration-tests.com')
225
+ ```
226
+
227
+ See the [full list of test accounts](https://haveibeenpwned.com/API/v3#TestAccounts) in the API documentation.
228
+
229
+ ## Rate Limiting
230
+
231
+ Rate limits vary by subscription level. When exceeded, a `RateLimitError` is raised with the `retry_after` attribute indicating seconds to wait:
232
+
233
+ ```ruby
234
+ begin
235
+ breaches = client.breached_account('test@example.com')
236
+ rescue HaveIBeenPwned::RateLimitError => e
237
+ sleep e.retry_after
238
+ retry
239
+ end
240
+ ```
241
+
242
+ ## Design Philosophy
243
+
244
+ This gem follows KISS, DRY, YAGNI, and shibui principles:
245
+
246
+ - **Simple**: Flat API surface with instance-based configuration
247
+ - **Clean**: Returns raw hashes, no unnecessary abstractions
248
+ - **Minimal**: Only essential dependencies (Faraday)
249
+ - **Clear**: Explicit error handling with semantic exceptions
250
+
251
+ ## Contributing
252
+
253
+ Bug reports and pull requests are welcome on GitHub.
254
+
255
+ ## License
256
+
257
+ This gem is available as open source under the terms of the [MIT License](LICENSE).
258
+
259
+ ## Attribution
260
+
261
+ This gem uses the Have I Been Pwned API. Please ensure proper attribution when using this service in your application.
262
+
263
+ Data sourced from [haveibeenpwned.com](https://haveibeenpwned.com) - check if your email has been compromised in a data breach.
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "erb"
6
+
7
+ module HaveIBeenPwned
8
+ class Client
9
+ BASE_URL = "https://haveibeenpwned.com/api/v3"
10
+
11
+ attr_reader :api_key, :user_agent
12
+
13
+ def initialize(api_key:, user_agent:)
14
+ @api_key = api_key
15
+ @user_agent = user_agent
16
+ end
17
+
18
+ # Breaches
19
+
20
+ def breached_account(account, truncate_response: true, domain: nil, include_unverified: true)
21
+ params = {
22
+ truncateResponse: truncate_response,
23
+ includeUnverified: include_unverified
24
+ }
25
+ params[:domain] = domain if domain
26
+
27
+ get("/breachedaccount/#{url_encode(account)}", params)
28
+ end
29
+
30
+ def breached_domain(domain)
31
+ get("/breacheddomain/#{domain}")
32
+ end
33
+
34
+ def subscribed_domains
35
+ get("/subscribeddomains")
36
+ end
37
+
38
+ def breaches(domain: nil, is_spam_list: nil)
39
+ params = {}
40
+ params[:domain] = domain if domain
41
+ params[:isSpamList] = is_spam_list unless is_spam_list.nil?
42
+
43
+ get("/breaches", params)
44
+ end
45
+
46
+ def breach(name)
47
+ get("/breach/#{name}")
48
+ end
49
+
50
+ def latest_breach
51
+ get("/latestbreach")
52
+ end
53
+
54
+ def data_classes
55
+ get("/dataclasses")
56
+ end
57
+
58
+ # Stealer Logs
59
+
60
+ def stealer_logs_by_email(email)
61
+ get("/stealerlogsbyemail/#{url_encode(email)}")
62
+ end
63
+
64
+ def stealer_logs_by_website_domain(domain)
65
+ get("/stealerlogsbywebsitedomain/#{domain}")
66
+ end
67
+
68
+ def stealer_logs_by_email_domain(domain)
69
+ get("/stealerlogsbyemaildomain/#{domain}")
70
+ end
71
+
72
+ # Pastes
73
+
74
+ def pastes(account)
75
+ get("/pasteaccount/#{url_encode(account)}")
76
+ end
77
+
78
+ # Subscription
79
+
80
+ def subscription_status
81
+ get("/subscription/status")
82
+ end
83
+
84
+ private
85
+
86
+ def connection
87
+ @connection ||= Faraday.new(url: BASE_URL) do |conn|
88
+ conn.headers["hibp-api-key"] = api_key
89
+ conn.headers["user-agent"] = user_agent
90
+ conn.adapter Faraday.default_adapter
91
+ end
92
+ end
93
+
94
+ def get(path, params = {})
95
+ response = connection.get(path, params)
96
+ handle_response(response)
97
+ end
98
+
99
+ def handle_response(response)
100
+ case response.status
101
+ when 200
102
+ parse_json(response.body)
103
+ when 400
104
+ raise BadRequestError, parse_error_message(response)
105
+ when 401
106
+ raise UnauthorizedError, parse_error_message(response)
107
+ when 403
108
+ raise ForbiddenError, parse_error_message(response)
109
+ when 404
110
+ raise NotFoundError, parse_error_message(response)
111
+ when 429
112
+ retry_after = response.headers["retry-after"]&.to_i
113
+ raise RateLimitError.new(parse_error_message(response), retry_after: retry_after)
114
+ when 503
115
+ raise ServiceUnavailableError, parse_error_message(response)
116
+ else
117
+ raise Error, "Unexpected response: #{response.status} - #{response.body}"
118
+ end
119
+ end
120
+
121
+ def parse_json(body)
122
+ return nil if body.nil? || body.empty?
123
+ JSON.parse(body)
124
+ end
125
+
126
+ def parse_error_message(response)
127
+ body = parse_json(response.body)
128
+ body.is_a?(Hash) ? body["message"] || body["statusCode"] : response.body
129
+ rescue JSON::ParserError
130
+ response.body
131
+ end
132
+
133
+ def url_encode(string)
134
+ ERB::Util.url_encode(string.to_s.strip)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HaveIBeenPwned
4
+ class Error < StandardError; end
5
+
6
+ class BadRequestError < Error; end
7
+
8
+ class UnauthorizedError < Error; end
9
+
10
+ class ForbiddenError < Error; end
11
+
12
+ class NotFoundError < Error; end
13
+
14
+ class RateLimitError < Error
15
+ attr_reader :retry_after
16
+
17
+ def initialize(message, retry_after: nil)
18
+ super(message)
19
+ @retry_after = retry_after
20
+ end
21
+ end
22
+
23
+ class ServiceUnavailableError < Error; end
24
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "digest"
5
+
6
+ module HaveIBeenPwned
7
+ class PwnedPasswords
8
+ BASE_URL = "https://api.pwnedpasswords.com"
9
+
10
+ class << self
11
+ def check(password, mode: :sha1, padding: false)
12
+ hash = hash_password(password, mode)
13
+ check_hash(hash, mode: mode, padding: padding)
14
+ end
15
+
16
+ def check_hash(hash, mode: :sha1, padding: false)
17
+ hash = hash.upcase
18
+ prefix = hash[0..4]
19
+ suffix = hash[5..-1]
20
+
21
+ response = range_search(prefix, mode: mode, padding: padding)
22
+
23
+ # Parse response and find matching suffix
24
+ response.each_line do |line|
25
+ hash_suffix, count = line.strip.split(":")
26
+ return count.to_i if hash_suffix == suffix
27
+ end
28
+
29
+ 0
30
+ end
31
+
32
+ def range_search(prefix, mode: :sha1, padding: false)
33
+ params = {}
34
+ params[:mode] = mode.to_s if mode == :ntlm
35
+
36
+ headers = {}
37
+ headers["Add-Padding"] = "true" if padding
38
+
39
+ response = connection.get("/range/#{prefix}", params) do |req|
40
+ headers.each { |key, value| req.headers[key] = value }
41
+ end
42
+
43
+ raise Error, "Unexpected response: #{response.status}" unless response.status == 200
44
+
45
+ response.body
46
+ end
47
+
48
+ private
49
+
50
+ def connection
51
+ @connection ||= Faraday.new(url: BASE_URL) do |conn|
52
+ conn.adapter Faraday.default_adapter
53
+ end
54
+ end
55
+
56
+ def hash_password(password, mode)
57
+ case mode
58
+ when :sha1
59
+ Digest::SHA1.hexdigest(password)
60
+ when :ntlm
61
+ # NTLM hashing would require additional gem, keeping simple for now
62
+ raise NotImplementedError, "NTLM hashing not yet implemented. Use check_hash with pre-computed NTLM hash."
63
+ else
64
+ raise ArgumentError, "Invalid mode: #{mode}. Use :sha1 or :ntlm"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HaveIBeenPwned
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "haveibeenpwned/version"
4
+ require_relative "haveibeenpwned/errors"
5
+ require_relative "haveibeenpwned/client"
6
+ require_relative "haveibeenpwned/pwned_passwords"
7
+
8
+ module HaveIBeenPwned
9
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sorbet-hibp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Angelos Kapsimanis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webmock
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: A simple, clean Ruby client for the Have I Been Pwned API v3, supporting
69
+ breach lookups, pastes, stealer logs, and pwned passwords
70
+ email:
71
+ - angelos@sorbet.ee
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - lib/haveibeenpwned.rb
79
+ - lib/haveibeenpwned/client.rb
80
+ - lib/haveibeenpwned/errors.rb
81
+ - lib/haveibeenpwned/pwned_passwords.rb
82
+ - lib/haveibeenpwned/version.rb
83
+ homepage: https://github.com/sorbet-ee/haveibeenpwned.
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://github.com/sorbet-ee/haveibeenpwned.
88
+ source_code_uri: https://github.com/sorbet-ee/haveibeenpwned.
89
+ changelog_uri: https://github.com/sorbet-ee/haveibeenpwned./blob/master/CHANGELOG.md
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: 2.7.0
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.6.9
105
+ specification_version: 4
106
+ summary: Ruby wrapper for the Have I Been Pwned API
107
+ test_files: []