xposedornot 1.0.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: d4c6f59511c16092f89dfb4fdcd4895ed02bcf0211c64e37d69590b0779a1dac
4
+ data.tar.gz: c72af4274f83ad204506429f0125a59c22107aecc43d642901bd037b31b77951
5
+ SHA512:
6
+ metadata.gz: 7dfcc6cd13edcf040601f28b05cdef3561837a90a968008deed98d4931465255c566796f4efa09958249b64de010f1c132884c007dc2ea96f11154369a947081
7
+ data.tar.gz: 43b6a2f5634af3d7edd0a3761648cd88c6197255da94062eccf4a75c6a2af9554eb4c4d1a11b07b86bc4d0a5674f7550ed3d0918ca0716a1b3821312ff60d1dc
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 XposedOrNot
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,287 @@
1
+ <p align="center">
2
+ <a href="https://xposedornot.com">
3
+ <img src="https://xposedornot.com/static/logos/xon.png" alt="XposedOrNot" width="200">
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">xposedornot</h1>
8
+
9
+ <p align="center">
10
+ Official Ruby SDK for the <a href="https://xposedornot.com">XposedOrNot</a> API<br>
11
+ <em>Check if your email has been exposed in data breaches</em>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://rubygems.org/gems/xposedornot"><img src="https://img.shields.io/gem/v/xposedornot.svg" alt="Gem Version"></a>
16
+ <a href="https://github.com/XposedOrNot/XposedOrNot-Ruby/actions"><img src="https://img.shields.io/github/actions/workflow/status/XposedOrNot/XposedOrNot-Ruby/build.yml?branch=main" alt="Build Status"></a>
17
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
18
+ <a href="https://www.ruby-lang.org"><img src="https://img.shields.io/badge/Ruby-%3E%3D%203.0-red.svg" alt="Ruby Version"></a>
19
+ </p>
20
+
21
+ ---
22
+
23
+ > **Note:** This SDK uses the free public API from [XposedOrNot.com](https://xposedornot.com) - a free service to check if your email has been compromised in data breaches. Visit the [XposedOrNot website](https://xposedornot.com) to learn more about the service and check your email manually.
24
+
25
+ ---
26
+
27
+ ## Table of Contents
28
+
29
+ - [Features](#features)
30
+ - [Installation](#installation)
31
+ - [Requirements](#requirements)
32
+ - [Quick Start](#quick-start)
33
+ - [API Reference](#api-reference)
34
+ - [check_email](#check_emailemail)
35
+ - [get_breaches](#get_breachesdomain)
36
+ - [breach_analytics](#breach_analyticsemail)
37
+ - [check_password](#check_passwordpassword)
38
+ - [Error Handling](#error-handling)
39
+ - [Rate Limits](#rate-limits)
40
+ - [Configuration](#configuration)
41
+ - [Contributing](#contributing)
42
+ - [License](#license)
43
+ - [Links](#links)
44
+
45
+ ---
46
+
47
+ ## Features
48
+
49
+ - **Simple API** - Easy-to-use methods for checking email breaches and password exposure
50
+ - **Detailed Analytics** - Get breach details, risk scores, and metrics
51
+ - **Password Safety** - Check password exposure using k-anonymity (only a hash prefix is sent)
52
+ - **Error Handling** - Custom error classes for different scenarios
53
+ - **Configurable** - Timeout, retries, custom headers, and Plus API support
54
+ - **Secure** - HTTPS enforced, input validation, no sensitive data logging
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ gem install xposedornot
60
+ ```
61
+
62
+ Or add to your Gemfile:
63
+
64
+ ```ruby
65
+ gem 'xposedornot'
66
+ ```
67
+
68
+ Then run:
69
+
70
+ ```bash
71
+ bundle install
72
+ ```
73
+
74
+ ## Requirements
75
+
76
+ - Ruby 3.0 or higher
77
+
78
+ ## Quick Start
79
+
80
+ ```ruby
81
+ require 'xposedornot'
82
+
83
+ client = XposedOrNot::Client.new
84
+
85
+ # Check if an email has been breached
86
+ result = client.check_email('test@example.com')
87
+
88
+ if result.breached?
89
+ puts "Email found in #{result.breaches.length} breaches:"
90
+ result.breaches.each { |breach| puts " - #{breach}" }
91
+ else
92
+ puts 'Good news! Email not found in any known breaches.'
93
+ end
94
+ ```
95
+
96
+ ## API Reference
97
+
98
+ ### Constructor
99
+
100
+ ```ruby
101
+ client = XposedOrNot::Client.new(api_key: nil, **options)
102
+ ```
103
+
104
+ See [Configuration](#configuration) for all available options.
105
+
106
+ ### Methods
107
+
108
+ #### `check_email(email)`
109
+
110
+ Check if an email address has been exposed in any data breaches. When an API key is configured, uses the Plus API for detailed results including `breach_id` and `password_risk`. Otherwise, uses the free API.
111
+
112
+ ```ruby
113
+ # Free API
114
+ client = XposedOrNot::Client.new
115
+ result = client.check_email('user@example.com')
116
+ puts result.breached? # => true / false
117
+ puts result.breaches # => ["Breach1", "Breach2"]
118
+
119
+ # Plus API (detailed results)
120
+ client = XposedOrNot::Client.new(api_key: 'your-api-key')
121
+ result = client.check_email('user@example.com')
122
+ puts result.breaches.first.breach_id
123
+ puts result.breaches.first.password_risk
124
+ ```
125
+
126
+ #### `get_breaches(domain:)`
127
+
128
+ Get a list of all known data breaches, optionally filtered by domain.
129
+
130
+ ```ruby
131
+ # Get all breaches
132
+ breaches = client.get_breaches
133
+
134
+ # Filter by domain
135
+ adobe_breaches = client.get_breaches(domain: 'adobe.com')
136
+
137
+ breaches.each do |breach|
138
+ puts "#{breach.breach_id} - #{breach.domain} (#{breach.exposed_records} records)"
139
+ end
140
+ ```
141
+
142
+ **Parameters:**
143
+
144
+ | Parameter | Type | Description |
145
+ |-----------|------|-------------|
146
+ | `domain` | `String` | Optional. Filter breaches by domain |
147
+
148
+ **Returns:** `Array<Models::Breach>` with properties such as `breach_id`, `breached_date`, `domain`, `industry`, `exposed_data`, `exposed_records`, and `verified`.
149
+
150
+ #### `breach_analytics(email)`
151
+
152
+ Get detailed breach analytics for an email address, including breach summaries, metrics, and paste exposures.
153
+
154
+ ```ruby
155
+ analytics = client.breach_analytics('user@example.com')
156
+
157
+ puts analytics.breaches_details.length
158
+ puts analytics.breaches_summary
159
+ puts analytics.breach_metrics
160
+ puts analytics.exposed_pastes
161
+ ```
162
+
163
+ #### `check_password(password)`
164
+
165
+ Check if a password has been exposed in data breaches. The password is hashed locally using Keccak-512 and only the first 10 hex characters of the digest are sent to the API, preserving anonymity via k-anonymity.
166
+
167
+ ```ruby
168
+ result = client.check_password('mypassword')
169
+
170
+ if result.exposed?
171
+ puts "This password has been seen #{result.count} time(s) in breaches!"
172
+ else
173
+ puts 'Password not found in any known breaches.'
174
+ end
175
+ ```
176
+
177
+ ## Error Handling
178
+
179
+ The library provides custom error classes for different scenarios:
180
+
181
+ ```ruby
182
+ begin
183
+ result = client.check_email('test@example.com')
184
+ rescue XposedOrNot::ValidationError => e
185
+ puts "Invalid input: #{e.message}"
186
+ rescue XposedOrNot::RateLimitError
187
+ puts 'Rate limited. Try again later.'
188
+ rescue XposedOrNot::NotFoundError
189
+ puts 'Email not found in any breaches.'
190
+ rescue XposedOrNot::AuthenticationError
191
+ puts 'Invalid API key.'
192
+ rescue XposedOrNot::NetworkError => e
193
+ puts "Network error: #{e.message}"
194
+ rescue XposedOrNot::APIError => e
195
+ puts "API error (#{e.status}): #{e.message}"
196
+ end
197
+ ```
198
+
199
+ ### Error Classes
200
+
201
+ | Error Class | Description |
202
+ |-------------|-------------|
203
+ | `XposedOrNotError` | Base error class for all errors |
204
+ | `ValidationError` | Invalid input (e.g., malformed email, blank password) |
205
+ | `RateLimitError` | API rate limit exceeded (HTTP 429) |
206
+ | `NotFoundError` | Resource not found (HTTP 404) |
207
+ | `AuthenticationError` | Authentication failed (HTTP 401/403) |
208
+ | `NetworkError` | Network connectivity issues or timeouts |
209
+ | `APIError` | General API error (exposes `.status` for the HTTP code) |
210
+
211
+ ## Rate Limits
212
+
213
+ The XposedOrNot API has the following rate limits:
214
+
215
+ - 2 requests per second
216
+ - 50-100 requests per hour
217
+ - 100-1000 requests per day
218
+
219
+ The client includes automatic retry with exponential backoff for `429` responses and built-in client-side throttling (1 request per second) for the free API.
220
+
221
+ ## Configuration
222
+
223
+ ```ruby
224
+ client = XposedOrNot::Client.new(
225
+ api_key: 'your-api-key', # Optional. Enables Plus API access
226
+ timeout: 15, # Request timeout in seconds (default: 30)
227
+ max_retries: 5, # Max retries on 429 responses (default: 3)
228
+ custom_headers: { 'X-Custom' => 'value' }
229
+ )
230
+ ```
231
+
232
+ ### Configuration Options
233
+
234
+ | Option | Type | Default | Description |
235
+ |--------|------|---------|-------------|
236
+ | `api_key` | `String` | `nil` | API key for Plus API access |
237
+ | `base_url` | `String` | `https://api.xposedornot.com` | Base URL for the free API |
238
+ | `plus_base_url` | `String` | `https://plus-api.xposedornot.com` | Base URL for the Plus API |
239
+ | `passwords_base_url` | `String` | `https://passwords.xposedornot.com/api` | Base URL for the password API |
240
+ | `timeout` | `Integer` | `30` | Request timeout in seconds |
241
+ | `max_retries` | `Integer` | `3` | Max retry attempts on 429 responses |
242
+ | `custom_headers` | `Hash` | `{}` | Custom headers for all requests |
243
+
244
+ ## Contributing
245
+
246
+ Contributions are welcome! Please feel free to submit a Pull Request.
247
+
248
+ 1. Fork the repository
249
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
250
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
251
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
252
+ 5. Open a Pull Request
253
+
254
+ ### Development Setup
255
+
256
+ ```bash
257
+ # Clone the repository
258
+ git clone https://github.com/XposedOrNot/XposedOrNot-Ruby.git
259
+ cd XposedOrNot-Ruby
260
+
261
+ # Install dependencies
262
+ bundle install
263
+
264
+ # Run tests
265
+ bundle exec rspec
266
+
267
+ # Run linter
268
+ bundle exec rubocop
269
+ ```
270
+
271
+ ## License
272
+
273
+ MIT - see the [LICENSE](LICENSE) file for details.
274
+
275
+ ## Links
276
+
277
+ - [XposedOrNot Website](https://xposedornot.com)
278
+ - [API Documentation](https://xposedornot.com/api_doc)
279
+ - [RubyGems Package](https://rubygems.org/gems/xposedornot)
280
+ - [GitHub Repository](https://github.com/XposedOrNot/XposedOrNot-Ruby)
281
+ - [XposedOrNot API Repository](https://github.com/XposedOrNot/XposedOrNot-API)
282
+
283
+ ---
284
+
285
+ <p align="center">
286
+ Made with care by <a href="https://xposedornot.com">XposedOrNot</a>
287
+ </p>
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+
7
+ module XposedOrNot
8
+ # Main client for interacting with the XposedOrNot API.
9
+ #
10
+ # @example Free API usage
11
+ # client = XposedOrNot::Client.new
12
+ # result = client.check_email("test@example.com")
13
+ #
14
+ # @example Plus API usage
15
+ # client = XposedOrNot::Client.new(api_key: "your-api-key")
16
+ # result = client.check_email("test@example.com")
17
+ class Client
18
+ include Endpoints::Email
19
+ include Endpoints::Breaches
20
+ include Endpoints::Password
21
+
22
+ # @return [Configuration] the client configuration
23
+ attr_reader :config
24
+
25
+ # @param api_key [String, nil] API key for Plus API access
26
+ # @param options [Hash] additional configuration options
27
+ # @option options [String] :base_url override default free API base URL
28
+ # @option options [String] :plus_base_url override default Plus API base URL
29
+ # @option options [String] :passwords_base_url override default passwords API base URL
30
+ # @option options [Integer] :timeout request timeout in seconds
31
+ # @option options [Integer] :max_retries max retries on 429 responses
32
+ # @option options [Hash] :custom_headers additional headers
33
+ def initialize(api_key: nil, **options)
34
+ @config = Configuration.new(api_key: api_key, **options)
35
+ @last_request_time = nil
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ private
40
+
41
+ # Resolves the base URL for a given API target.
42
+ #
43
+ # @param base [Symbol] one of :free, :plus, :passwords
44
+ # @return [String]
45
+ def base_url_for(base)
46
+ case base
47
+ when :plus
48
+ @config.plus_base_url
49
+ when :passwords
50
+ @config.passwords_base_url
51
+ else
52
+ @config.base_url
53
+ end
54
+ end
55
+
56
+ # Enforces client-side rate limiting for free API (1 req/sec).
57
+ # Skipped when an API key is configured.
58
+ #
59
+ # @return [void]
60
+ def rate_limit!
61
+ return if @config.plus_api?
62
+
63
+ @mutex.synchronize do
64
+ if @last_request_time
65
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
66
+ sleep(1.0 - elapsed) if elapsed < 1.0
67
+ end
68
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ end
70
+ end
71
+
72
+ # Builds a Faraday connection for the given base URL.
73
+ #
74
+ # @param url [String] the base URL
75
+ # @return [Faraday::Connection]
76
+ def connection(url)
77
+ Faraday.new(url: url) do |f|
78
+ f.request :retry,
79
+ max: @config.max_retries,
80
+ interval: 1,
81
+ backoff_factor: 2,
82
+ retry_statuses: [429],
83
+ exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
84
+
85
+ f.options.timeout = @config.timeout
86
+ f.options.open_timeout = @config.timeout
87
+
88
+ f.headers["Content-Type"] = "application/json"
89
+ f.headers["Accept"] = "application/json"
90
+ f.headers["x-api-key"] = @config.api_key if @config.plus_api?
91
+
92
+ @config.custom_headers.each do |key, value|
93
+ f.headers[key.to_s] = value.to_s
94
+ end
95
+
96
+ f.adapter Faraday.default_adapter
97
+ end
98
+ end
99
+
100
+ # Makes an HTTP request and handles error responses.
101
+ #
102
+ # @param method [Symbol] HTTP method (:get, :post, etc.)
103
+ # @param path [String] request path
104
+ # @param base [Symbol] API target (:free, :plus, :passwords)
105
+ # @param params [Hash] query parameters
106
+ # @return [Hash] parsed JSON response
107
+ # @raise [RateLimitError, NotFoundError, AuthenticationError, APIError, NetworkError]
108
+ def request(method, path, base: :free, params: {})
109
+ rate_limit!
110
+
111
+ url = base_url_for(base)
112
+ conn = connection(url)
113
+
114
+ response = conn.public_send(method, path) do |req|
115
+ req.params.update(params) unless params.empty?
116
+ end
117
+
118
+ handle_response(response)
119
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
120
+ raise NetworkError, "Network error: #{e.message}"
121
+ end
122
+
123
+ # Parses and validates an HTTP response.
124
+ #
125
+ # @param response [Faraday::Response]
126
+ # @return [Hash] parsed JSON body
127
+ # @raise [RateLimitError, NotFoundError, AuthenticationError, APIError]
128
+ def handle_response(response)
129
+ case response.status
130
+ when 200..299
131
+ parse_body(response.body)
132
+ when 401, 403
133
+ raise AuthenticationError, "Authentication failed (HTTP #{response.status})"
134
+ when 404
135
+ raise NotFoundError, "Resource not found (HTTP 404)"
136
+ when 429
137
+ raise RateLimitError, "Rate limit exceeded (HTTP 429)"
138
+ else
139
+ raise APIError.new("API error (HTTP #{response.status}): #{response.body}", status: response.status)
140
+ end
141
+ end
142
+
143
+ # Safely parses a JSON response body.
144
+ #
145
+ # @param body [String] raw response body
146
+ # @return [Hash]
147
+ # @raise [APIError] if the body is not valid JSON
148
+ def parse_body(body)
149
+ return {} if body.nil? || body.strip.empty?
150
+
151
+ JSON.parse(body)
152
+ rescue JSON::ParserError => e
153
+ raise APIError.new("Invalid JSON response: #{e.message}")
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ # Configuration for the XposedOrNot client.
5
+ #
6
+ # @example
7
+ # config = XposedOrNot::Configuration.new(api_key: "my-key", timeout: 15)
8
+ class Configuration
9
+ # @return [String] base URL for the free API
10
+ attr_reader :base_url
11
+
12
+ # @return [String] base URL for the Plus (commercial) API
13
+ attr_reader :plus_base_url
14
+
15
+ # @return [String] base URL for the password check API
16
+ attr_reader :passwords_base_url
17
+
18
+ # @return [Integer] request timeout in seconds
19
+ attr_accessor :timeout
20
+
21
+ # @return [Integer] maximum number of retries on 429 responses
22
+ attr_accessor :max_retries
23
+
24
+ # @return [String, nil] API key for Plus API access
25
+ attr_accessor :api_key
26
+
27
+ # @return [Hash] custom headers to include in every request
28
+ attr_accessor :custom_headers
29
+
30
+ # @return [Boolean] whether to allow insecure (HTTP) base URLs
31
+ attr_reader :allow_insecure
32
+
33
+ DEFAULT_BASE_URL = "https://api.xposedornot.com"
34
+ DEFAULT_PLUS_BASE_URL = "https://plus-api.xposedornot.com"
35
+ DEFAULT_PASSWORDS_BASE_URL = "https://passwords.xposedornot.com/api"
36
+ DEFAULT_TIMEOUT = 30
37
+ DEFAULT_MAX_RETRIES = 3
38
+
39
+ # @param base_url [String] base URL for the free API
40
+ # @param plus_base_url [String] base URL for the Plus API
41
+ # @param passwords_base_url [String] base URL for the password API
42
+ # @param timeout [Integer] request timeout in seconds
43
+ # @param max_retries [Integer] max retries on 429 responses
44
+ # @param api_key [String, nil] API key for Plus API
45
+ # @param custom_headers [Hash] additional headers
46
+ # @param allow_insecure [Boolean] allow HTTP URLs (default false, for testing only)
47
+ def initialize(
48
+ base_url: DEFAULT_BASE_URL,
49
+ plus_base_url: DEFAULT_PLUS_BASE_URL,
50
+ passwords_base_url: DEFAULT_PASSWORDS_BASE_URL,
51
+ timeout: DEFAULT_TIMEOUT,
52
+ max_retries: DEFAULT_MAX_RETRIES,
53
+ api_key: nil,
54
+ custom_headers: {},
55
+ allow_insecure: false
56
+ )
57
+ @allow_insecure = allow_insecure
58
+ @base_url = base_url
59
+ @plus_base_url = plus_base_url
60
+ @passwords_base_url = passwords_base_url
61
+ @timeout = timeout
62
+ @max_retries = max_retries
63
+ @api_key = api_key
64
+ @custom_headers = custom_headers
65
+
66
+ validate!
67
+ end
68
+
69
+ # Sets the base URL for the free API.
70
+ #
71
+ # @param url [String]
72
+ def base_url=(url)
73
+ validate_url!(:base_url, url)
74
+ @base_url = url
75
+ end
76
+
77
+ # Sets the base URL for the Plus API.
78
+ #
79
+ # @param url [String]
80
+ def plus_base_url=(url)
81
+ validate_url!(:plus_base_url, url)
82
+ @plus_base_url = url
83
+ end
84
+
85
+ # Sets the base URL for the password check API.
86
+ #
87
+ # @param url [String]
88
+ def passwords_base_url=(url)
89
+ validate_url!(:passwords_base_url, url)
90
+ @passwords_base_url = url
91
+ end
92
+
93
+ # Returns true if an API key is configured (Plus API access).
94
+ #
95
+ # @return [Boolean]
96
+ def plus_api?
97
+ !@api_key.nil? && !@api_key.empty?
98
+ end
99
+
100
+ # Redacts sensitive fields from the inspect output.
101
+ #
102
+ # @return [String]
103
+ def inspect
104
+ "#<#{self.class.name} base_url=#{@base_url.inspect} api_key=#{@api_key ? '[REDACTED]' : 'nil'}>"
105
+ end
106
+
107
+ private
108
+
109
+ # Validates all URL fields use HTTPS unless allow_insecure is set.
110
+ #
111
+ # @raise [ValidationError] if any URL does not start with https://
112
+ def validate!
113
+ return if @allow_insecure
114
+
115
+ validate_url!(:base_url, @base_url)
116
+ validate_url!(:plus_base_url, @plus_base_url)
117
+ validate_url!(:passwords_base_url, @passwords_base_url)
118
+ end
119
+
120
+ # Validates a single URL uses HTTPS.
121
+ #
122
+ # @param name [Symbol] the field name (for error messages)
123
+ # @param url [String] the URL to validate
124
+ # @raise [ValidationError] if the URL does not start with https://
125
+ def validate_url!(name, url)
126
+ return if @allow_insecure
127
+ return if url.start_with?("https://")
128
+
129
+ raise ValidationError, "#{name} must use HTTPS (got: #{url})"
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Endpoints
5
+ # Breaches listing endpoint.
6
+ module Breaches
7
+ # Get a list of all known breaches, optionally filtered by domain.
8
+ #
9
+ # @param domain [String, nil] optional domain to filter results
10
+ # @return [Array<Models::Breach>] list of breach records
11
+ def get_breaches(domain: nil)
12
+ params = {}
13
+ params[:domain] = domain if domain
14
+
15
+ response = request(:get, "/v1/breaches", base: :free, params: params)
16
+ raw = response["exposedBreaches"] || []
17
+ raw.map { |b| Models::Breach.new(b) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module XposedOrNot
6
+ module Endpoints
7
+ # Email-related API endpoints.
8
+ module Email
9
+ # Check if an email has been exposed in data breaches.
10
+ #
11
+ # When an API key is configured, uses the Plus API for detailed results.
12
+ # Otherwise, uses the free API.
13
+ #
14
+ # @param email [String] the email address to check
15
+ # @return [Models::EmailBreachResponse, Models::EmailBreachDetailedResponse]
16
+ # @raise [ValidationError] if the email is invalid
17
+ # @raise [NotFoundError] if the email is not found in any breaches
18
+ def check_email(email)
19
+ Utils.validate_email(email)
20
+
21
+ if @config.plus_api?
22
+ check_email_detailed(email)
23
+ else
24
+ check_email_free(email)
25
+ end
26
+ end
27
+
28
+ # Get breach analytics for an email address.
29
+ #
30
+ # @param email [String] the email address to analyze
31
+ # @return [Models::BreachAnalyticsResponse]
32
+ # @raise [ValidationError] if the email is invalid
33
+ def breach_analytics(email)
34
+ Utils.validate_email(email)
35
+
36
+ response = request(:get, "/v1/breach-analytics", base: :free, params: { email: email })
37
+ Models::BreachAnalyticsResponse.new(response)
38
+ end
39
+
40
+ private
41
+
42
+ # @param email [String]
43
+ # @return [Models::EmailBreachResponse]
44
+ def check_email_free(email)
45
+ response = request(:get, "/v1/check-email/#{URI.encode_www_form_component(email)}", base: :free)
46
+ Models::EmailBreachResponse.new(response)
47
+ end
48
+
49
+ # @param email [String]
50
+ # @return [Models::EmailBreachDetailedResponse]
51
+ def check_email_detailed(email)
52
+ response = request(:get, "/v3/check-email/#{URI.encode_www_form_component(email)}", base: :plus, params: { detailed: true })
53
+ Models::EmailBreachDetailedResponse.new(response)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Endpoints
5
+ # Password exposure check endpoint.
6
+ module Password
7
+ # Check if a password has been exposed in data breaches.
8
+ #
9
+ # The password is hashed locally using Keccak-512 and only the first
10
+ # 10 hex characters of the digest are sent to the API for an anonymous
11
+ # lookup.
12
+ #
13
+ # @param password [String] the plaintext password to check
14
+ # @return [Models::PasswordCheckResponse]
15
+ # @raise [ValidationError] if the password is blank
16
+ def check_password(password)
17
+ Utils.validate_password(password)
18
+
19
+ hash_prefix = Utils.keccak_hash_prefix(password)
20
+ response = request(:get, "/v1/pass/anon/#{hash_prefix}", base: :passwords)
21
+ Models::PasswordCheckResponse.new(response)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ # Base error class for all XposedOrNot errors.
5
+ class XposedOrNotError < StandardError; end
6
+
7
+ # Raised when the API returns a 429 Too Many Requests response.
8
+ class RateLimitError < XposedOrNotError; end
9
+
10
+ # Raised when the requested resource is not found (404).
11
+ class NotFoundError < XposedOrNotError; end
12
+
13
+ # Raised when authentication fails (401/403).
14
+ class AuthenticationError < XposedOrNotError; end
15
+
16
+ # Raised when input validation fails before making a request.
17
+ class ValidationError < XposedOrNotError; end
18
+
19
+ # Raised when a network-level error occurs (timeouts, connection refused, etc.).
20
+ class NetworkError < XposedOrNotError; end
21
+
22
+ # Raised when the API returns an unexpected error response.
23
+ class APIError < XposedOrNotError
24
+ # @return [Integer, nil] the HTTP status code
25
+ attr_reader :status
26
+
27
+ # @param message [String] the error message
28
+ # @param status [Integer, nil] the HTTP status code
29
+ def initialize(message = nil, status: nil)
30
+ @status = status
31
+ super(message)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Models
5
+ # Represents a single data breach record.
6
+ class Breach
7
+ # @return [String] unique breach identifier
8
+ attr_reader :breach_id
9
+
10
+ # @return [String] date the breach occurred
11
+ attr_reader :breached_date
12
+
13
+ # @return [String] domain affected by the breach
14
+ attr_reader :domain
15
+
16
+ # @return [String] industry of the breached organization
17
+ attr_reader :industry
18
+
19
+ # @return [String] types of data exposed
20
+ attr_reader :exposed_data
21
+
22
+ # @return [Integer] number of records exposed
23
+ attr_reader :exposed_records
24
+
25
+ # @return [Boolean] whether the breach is verified
26
+ attr_reader :verified
27
+
28
+ # @return [String, nil] URL of the breach logo
29
+ attr_reader :logo
30
+
31
+ # @return [String, nil] risk level of password exposure
32
+ attr_reader :password_risk
33
+
34
+ # @return [Boolean, nil] whether the breach is searchable
35
+ attr_reader :searchable
36
+
37
+ # @return [String, nil] description of the exposure
38
+ attr_reader :xposure_desc
39
+
40
+ # @param data [Hash] raw breach data from the API
41
+ def initialize(data)
42
+ @breach_id = data["breachID"] || data["breach_id"]
43
+ @breached_date = data["breachedDate"] || data["breached_date"]
44
+ @domain = data["domain"]
45
+ @industry = data["industry"]
46
+ @exposed_data = data["exposedData"] || data["xposed_data"]
47
+ @exposed_records = data["exposedRecords"] || data["xposed_records"]
48
+ @verified = data["verified"]
49
+ @logo = data["logo"]
50
+ @password_risk = data["password_risk"]
51
+ @searchable = data["searchable"]
52
+ @xposure_desc = data["xposure_desc"]
53
+ end
54
+
55
+ # @return [Hash] hash representation of the breach
56
+ def to_h
57
+ {
58
+ breach_id: @breach_id,
59
+ breached_date: @breached_date,
60
+ domain: @domain,
61
+ industry: @industry,
62
+ exposed_data: @exposed_data,
63
+ exposed_records: @exposed_records,
64
+ verified: @verified,
65
+ logo: @logo,
66
+ password_risk: @password_risk,
67
+ searchable: @searchable,
68
+ xposure_desc: @xposure_desc
69
+ }.compact
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Models
5
+ # Response from the breach analytics endpoint.
6
+ class BreachAnalyticsResponse
7
+ # @return [Array<Breach>] detailed breach records
8
+ attr_reader :breaches_details
9
+
10
+ # @return [Hash] summary of breaches
11
+ attr_reader :breaches_summary
12
+
13
+ # @return [Hash] breach metrics
14
+ attr_reader :breach_metrics
15
+
16
+ # @return [Hash] pastes summary
17
+ attr_reader :pastes_summary
18
+
19
+ # @return [Array<Hash>] exposed pastes
20
+ attr_reader :exposed_pastes
21
+
22
+ # @param data [Hash] raw response data from the API
23
+ def initialize(data)
24
+ exposed = data["ExposedBreaches"] || {}
25
+ details = exposed["breaches_details"] || []
26
+ @breaches_details = details.map { |b| Breach.new(b) }
27
+ @breaches_summary = data["BreachesSummary"] || {}
28
+ @breach_metrics = data["BreachMetrics"] || {}
29
+ @pastes_summary = data["PastesSummary"] || {}
30
+ @exposed_pastes = data["ExposedPastes"] || []
31
+ end
32
+
33
+ # @return [Hash] hash representation
34
+ def to_h
35
+ {
36
+ breaches_details: @breaches_details.map(&:to_h),
37
+ breaches_summary: @breaches_summary,
38
+ breach_metrics: @breach_metrics,
39
+ pastes_summary: @pastes_summary,
40
+ exposed_pastes: @exposed_pastes
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Models
5
+ # Response from the Plus API detailed email breach check endpoint.
6
+ class EmailBreachDetailedResponse
7
+ # @return [String] status from the API
8
+ attr_reader :status
9
+
10
+ # @return [String] the queried email address
11
+ attr_reader :email
12
+
13
+ # @return [Array<Breach>] detailed breach records
14
+ attr_reader :breaches
15
+
16
+ # @param data [Hash] raw response data from the Plus API
17
+ def initialize(data)
18
+ @status = data["status"]
19
+ @email = data["email"]
20
+ raw_breaches = data["breaches"] || []
21
+ @breaches = raw_breaches.map { |b| Breach.new(b) }
22
+ end
23
+
24
+ # @return [Boolean] true if the email was found in any breaches
25
+ def breached?
26
+ !@breaches.empty?
27
+ end
28
+
29
+ # @return [Integer] number of breaches found
30
+ def count
31
+ @breaches.length
32
+ end
33
+
34
+ # @return [Hash] hash representation
35
+ def to_h
36
+ {
37
+ status: @status,
38
+ email: @email,
39
+ breaches: @breaches.map(&:to_h),
40
+ breached: breached?,
41
+ count: count
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Models
5
+ # Response from the free email breach check endpoint.
6
+ class EmailBreachResponse
7
+ # @return [Array<String>] list of breach names
8
+ attr_reader :breaches
9
+
10
+ # @param data [Hash] raw response data from the API
11
+ def initialize(data)
12
+ raw = data["breaches"]
13
+ @breaches = if raw.is_a?(Array) && raw.first.is_a?(Array)
14
+ raw.flatten
15
+ elsif raw.is_a?(Array)
16
+ raw
17
+ else
18
+ []
19
+ end
20
+ end
21
+
22
+ # @return [Boolean] true if the email was found in any breaches
23
+ def breached?
24
+ !@breaches.empty?
25
+ end
26
+
27
+ # @return [Integer] number of breaches found
28
+ def count
29
+ @breaches.length
30
+ end
31
+
32
+ # @return [Hash] hash representation
33
+ def to_h
34
+ { breaches: @breaches, breached: breached?, count: count }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ module Models
5
+ # Response from the password exposure check endpoint.
6
+ class PasswordCheckResponse
7
+ # @return [String] the anonymous hash prefix used for the search
8
+ attr_reader :anon
9
+
10
+ # @return [String] character composition breakdown (e.g. "D:3;A:8;S:0;L:11")
11
+ attr_reader :char
12
+
13
+ # @return [Integer] number of times the password was seen in breaches
14
+ attr_reader :count
15
+
16
+ # @param data [Hash] raw response data from the API
17
+ def initialize(data)
18
+ search = data["SearchPassAnon"] || {}
19
+ @anon = search["anon"]
20
+ @char = search["char"]
21
+ @count = (search["count"] || "0").to_i
22
+ end
23
+
24
+ # @return [Boolean] true if the password has been exposed
25
+ def exposed?
26
+ @count.positive?
27
+ end
28
+
29
+ # @return [Hash] hash representation
30
+ def to_h
31
+ { anon: @anon, char: @char, count: @count, exposed: exposed? }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/keccak"
4
+
5
+ module XposedOrNot
6
+ # Utility methods for the XposedOrNot client.
7
+ module Utils
8
+ EMAIL_REGEX = /\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/
9
+
10
+ module_function
11
+
12
+ # Validates that the given string is a plausible email address.
13
+ #
14
+ # @param email [String] the email address to validate
15
+ # @raise [ValidationError] if the email is invalid
16
+ # @return [void]
17
+ def validate_email(email)
18
+ raise ValidationError, "Email must be a non-empty string" if email.nil? || email.strip.empty?
19
+ raise ValidationError, "Invalid email format: #{email}" unless email.match?(EMAIL_REGEX)
20
+ end
21
+
22
+ # Validates that the given string is a non-empty password.
23
+ #
24
+ # @param password [String] the password to validate
25
+ # @raise [ValidationError] if the password is blank
26
+ # @return [void]
27
+ def validate_password(password)
28
+ raise ValidationError, "Password must be a non-empty string" if password.nil? || password.empty?
29
+ end
30
+
31
+ # Hashes a password with original Keccak-512 and returns the first 10 hex
32
+ # characters of the digest (the "anonymous prefix").
33
+ #
34
+ # @param password [String] the plaintext password
35
+ # @return [String] first 10 hex characters of the Keccak-512 digest
36
+ def keccak_hash_prefix(password)
37
+ digest = Digest::Keccak.new(512)
38
+ full_hash = digest.hexdigest(password)
39
+ full_hash[0, 10]
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XposedOrNot
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "xposedornot/version"
4
+ require_relative "xposedornot/errors"
5
+ require_relative "xposedornot/configuration"
6
+ require_relative "xposedornot/utils"
7
+ require_relative "xposedornot/models/breach"
8
+ require_relative "xposedornot/models/email_breach_response"
9
+ require_relative "xposedornot/models/email_breach_detailed_response"
10
+ require_relative "xposedornot/models/breach_analytics_response"
11
+ require_relative "xposedornot/models/password_check_response"
12
+ require_relative "xposedornot/endpoints/email"
13
+ require_relative "xposedornot/endpoints/breaches"
14
+ require_relative "xposedornot/endpoints/password"
15
+ require_relative "xposedornot/client"
16
+
17
+ # XposedOrNot API client library for checking data breaches.
18
+ #
19
+ # @example
20
+ # client = XposedOrNot::Client.new
21
+ # result = client.check_email("test@example.com")
22
+ module XposedOrNot
23
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xposedornot
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - XposedOrNot
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: digest-keccak
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ description: A Ruby gem for interacting with the XposedOrNot API to check email breaches,
56
+ password exposure, and breach analytics. Supports both the free and commercial Plus
57
+ API.
58
+ email:
59
+ - deva@xposedornot.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - lib/xposedornot.rb
67
+ - lib/xposedornot/client.rb
68
+ - lib/xposedornot/configuration.rb
69
+ - lib/xposedornot/endpoints/breaches.rb
70
+ - lib/xposedornot/endpoints/email.rb
71
+ - lib/xposedornot/endpoints/password.rb
72
+ - lib/xposedornot/errors.rb
73
+ - lib/xposedornot/models/breach.rb
74
+ - lib/xposedornot/models/breach_analytics_response.rb
75
+ - lib/xposedornot/models/email_breach_detailed_response.rb
76
+ - lib/xposedornot/models/email_breach_response.rb
77
+ - lib/xposedornot/models/password_check_response.rb
78
+ - lib/xposedornot/utils.rb
79
+ - lib/xposedornot/version.rb
80
+ homepage: https://xposedornot.com
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ homepage_uri: https://xposedornot.com
85
+ source_code_uri: https://github.com/XposedOrNot/XposedOrNot-Ruby
86
+ changelog_uri: https://github.com/XposedOrNot/XposedOrNot-Ruby/blob/main/CHANGELOG.md
87
+ rubygems_mfa_required: 'true'
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 3.0.0
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.4.20
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Ruby client library for the XposedOrNot data breach API
107
+ test_files: []