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 +7 -0
- data/LICENSE +21 -0
- data/README.md +287 -0
- data/lib/xposedornot/client.rb +156 -0
- data/lib/xposedornot/configuration.rb +132 -0
- data/lib/xposedornot/endpoints/breaches.rb +21 -0
- data/lib/xposedornot/endpoints/email.rb +57 -0
- data/lib/xposedornot/endpoints/password.rb +25 -0
- data/lib/xposedornot/errors.rb +34 -0
- data/lib/xposedornot/models/breach.rb +73 -0
- data/lib/xposedornot/models/breach_analytics_response.rb +45 -0
- data/lib/xposedornot/models/email_breach_detailed_response.rb +46 -0
- data/lib/xposedornot/models/email_breach_response.rb +38 -0
- data/lib/xposedornot/models/password_check_response.rb +35 -0
- data/lib/xposedornot/utils.rb +42 -0
- data/lib/xposedornot/version.rb +5 -0
- data/lib/xposedornot.rb +23 -0
- metadata +107 -0
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
|
data/lib/xposedornot.rb
ADDED
|
@@ -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: []
|