hookd-client 1.2.2 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e020dd2f4705d56fdc57c2a7da6daba65231a9676a534037104d98cd401bcdaa
4
- data.tar.gz: 505b0f7151a9b7dfc02638b7b6c5978be6ef9832987c85b2b6b27ca7262cf8d5
3
+ metadata.gz: 5ff2c6d9ea446cd5631e2c3f4d3f27a0586a130acaf6bf9e10f9a7fb6613efc3
4
+ data.tar.gz: 38021e8c18baee9ff374c60d305cc6312cf024a2fff120e926cfa89d9d44ea52
5
5
  SHA512:
6
- metadata.gz: 32da3c04302a2e04ce0d1ea42b05f438d80e12fadfcae77de8720d1e5894d56ada4ab8b5db77d934b3bec64378d8140b747b761996931c39bd1f9b70656f94e9
7
- data.tar.gz: 78080b0c8e32c86d7634e8d137bfa8a9471e03351a4449ec6f969f12d452ffbd912d6e9ad91ef9ce832853dcd8d3c102ad567d6d3d706e1f49349158c1ebebb4
6
+ metadata.gz: 3cc77be594c69256a2de2e31a23a7654dddbf94c50f0402fa327e3865e2fa25af4c4c4dfab6e9ca641aa611b20da6e22760646d570676a4a87e92d08382f3af8
7
+ data.tar.gz: 1d2da85d42bfff6ba7bcf2e1211b418c7bc8944ce8119169388e1b51cec085d64a54ceb4372ea5b5869714c792836cd193b1600b526c40021ead72cf8a492d28
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Hookd Ruby Client
2
2
 
3
- Ruby client library for [Hookd](https://github.com/JoshuaMart/hookd/server), a DNS/HTTP interaction server for security testing and debugging.
3
+ Ruby client library for [Hookd](https://github.com/JoshuaMart/hookd/server), a DNS/HTTP/SMTP interaction server for security testing and debugging.
4
4
 
5
5
  ## Installation
6
6
 
@@ -35,6 +35,7 @@ hook = client.register
35
35
  puts "DNS endpoint: #{hook.dns}"
36
36
  puts "HTTP endpoint: #{hook.http}"
37
37
  puts "HTTPS endpoint: #{hook.https}"
38
+ puts "Mail endpoint: #{hook.smtp}" if hook.smtp
38
39
 
39
40
  # Make a request to the HTTP endpoint to simulate an interaction
40
41
  Typhoeus.get(hook.http)
@@ -46,6 +47,8 @@ interactions.each do |interaction|
46
47
  puts "DNS query: #{interaction.data}"
47
48
  elsif interaction.http?
48
49
  puts "HTTP request: #{interaction.data}"
50
+ elsif interaction.smtp?
51
+ puts "Mail from #{interaction.data['mail_from']}: #{interaction.data['subject']}"
49
52
  end
50
53
  end
51
54
  ```
@@ -95,6 +98,8 @@ results.each do |hook_id, result|
95
98
  puts " - DNS: #{interaction.data['qname']} (#{interaction.data['qtype']})"
96
99
  elsif interaction.http?
97
100
  puts " - HTTP: #{interaction.data['method']} #{interaction.data['path']}"
101
+ elsif interaction.smtp?
102
+ puts " - SMTP: #{interaction.data['mail_from']} (#{interaction.data['subject']})"
98
103
  end
99
104
  end
100
105
  end
@@ -108,6 +113,10 @@ The client requires two configuration parameters:
108
113
  - `server`: The Hookd server URL (e.g., `https://hookd.example.com`)
109
114
  - `token`: Authentication token for API access
110
115
 
116
+ An optional `max_response_bytes:` caps the response payload the client will read
117
+ (64 MiB by default; zero or less disables it). Exceeding it raises
118
+ `Hookd::ResponseTooLargeError`.
119
+
111
120
  ### API Reference
112
121
 
113
122
  #### `Hookd::Client`
@@ -116,7 +125,7 @@ Main client class for interacting with the Hookd server.
116
125
 
117
126
  ##### `#register(count: nil)`
118
127
 
119
- Register one or more hooks and get DNS/HTTP endpoints.
128
+ Register one or more hooks and get their DNS, HTTP and (when the server runs a mail listener) SMTP endpoints.
120
129
 
121
130
  **Single hook (default):**
122
131
  ```ruby
@@ -130,8 +139,20 @@ hooks = client.register(count: 5)
130
139
  # => [#<Hookd::Hook id="abc123" ...>, #<Hookd::Hook id="def456" ...>, ...]
131
140
  ```
132
141
 
142
+ **Long-lived hook (survives restarts, for stored-XSS style detection):**
143
+ ```ruby
144
+ hook = client.register(ttl: '7d', metadata: { target: 'acme', field: 'profile.bio' })
145
+ # => #<Hookd::Hook id="abc123" ...>
146
+ hook.expires_at # => "2025-10-08T10:30:00Z"
147
+ hook.metadata # => {"target"=>"acme", "field"=>"profile.bio"}
148
+ ```
149
+
133
150
  Parameters:
134
151
  - `count` (Integer, optional) - Number of hooks to create (default: 1)
152
+ - `ttl` (String, optional) - Lifetime as a Go duration (`"168h"`) or day count
153
+ (`"7d"`); a value above the server's ephemeral `hook_ttl` registers a durable
154
+ long-lived hook. Omit for an ephemeral hook.
155
+ - `metadata` (Hash, optional) - Stored with the hook and echoed back on poll
135
156
 
136
157
  Returns:
137
158
  - `Hookd::Hook` object when `count` is 1 or not specified
@@ -143,6 +164,21 @@ Raises:
143
164
  - `Hookd::ServerError` - Server error (5xx)
144
165
  - `Hookd::ConnectionError` - Connection failed
145
166
 
167
+ ##### `#activity`
168
+
169
+ List the long-lived hooks that currently have pending interactions, so you can
170
+ discover which fired without polling each one; drain the details with `#poll`.
171
+
172
+ ```ruby
173
+ client.activity.each do |a|
174
+ puts "#{a.hook.id} fired #{a.pending_count} time(s), meta=#{a.hook.metadata}"
175
+ client.poll(a.hook.id)
176
+ end
177
+ # => [#<Hookd::HookActivity hook=abc123 pending=3>, ...]
178
+ ```
179
+
180
+ Returns: Array of `Hookd::HookActivity` (empty when none fired or long-lived is disabled)
181
+
146
182
  ##### `#poll(hook_id)`
147
183
 
148
184
  Poll for interactions captured by a single hook.
@@ -232,20 +268,33 @@ Attributes:
232
268
  - `dns` (String) - DNS endpoint
233
269
  - `http` (String) - HTTP endpoint
234
270
  - `https` (String) - HTTPS endpoint
271
+ - `smtp` (String, nil) - Mail address (nil unless the server runs a mail listener)
235
272
  - `created_at` (String) - Creation timestamp
273
+ - `expires_at` (String, nil) - Expiry timestamp (long-lived hooks)
274
+ - `metadata` (Hash, nil) - Metadata attached at registration
275
+
276
+ #### `Hookd::HookActivity`
277
+
278
+ Represents a long-lived hook that has pending interactions (returned by `#activity`).
279
+
280
+ Attributes:
281
+ - `hook` (`Hookd::Hook`) - The long-lived hook that fired
282
+ - `pending_count` (Integer) - Number of interactions awaiting poll
283
+ - `last_interaction_at` (String) - Timestamp of the most recent interaction
236
284
 
237
285
  #### `Hookd::Interaction`
238
286
 
239
- Represents a captured DNS or HTTP interaction.
287
+ Represents a captured DNS, HTTP or SMTP interaction.
240
288
 
241
289
  Attributes:
242
- - `type` (String) - Interaction type ("dns" or "http")
290
+ - `type` (String) - Interaction type ("dns", "http" or "smtp")
243
291
  - `timestamp` (String) - When the interaction was captured
244
292
  - `data` (Hash) - Interaction details
245
293
 
246
294
  Methods:
247
295
  - `#dns?` - Returns true if this is a DNS interaction
248
296
  - `#http?` - Returns true if this is an HTTP interaction
297
+ - `#smtp?` - Returns true if this is an SMTP interaction
249
298
 
250
299
  ### Error Handling
251
300
 
@@ -269,3 +318,4 @@ Exception hierarchy:
269
318
  - `Hookd::NotFoundError` - 404 Not Found
270
319
  - `Hookd::ServerError` - 5xx Server Error
271
320
  - `Hookd::ConnectionError` - Network/connection errors
321
+ - `Hookd::ResponseTooLargeError` - Response above `max_response_bytes`
data/lib/hookd/client.rb CHANGED
@@ -6,11 +6,19 @@ require 'json'
6
6
  module Hookd
7
7
  # HTTP client for interacting with Hookd server
8
8
  class Client
9
- attr_reader :server, :token
9
+ # Caps the payload materialised as a String. Generous, since a poll can
10
+ # return many interactions with full bodies. Zero or less disables it.
11
+ DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
10
12
 
11
- def initialize(server:, token:)
13
+ # How much of an error response is quoted back in the raised message.
14
+ ERROR_BODY_EXCERPT_BYTES = 1024
15
+
16
+ attr_reader :server, :token, :max_response_bytes
17
+
18
+ def initialize(server:, token:, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
12
19
  @server = server
13
20
  @token = token
21
+ @max_response_bytes = max_response_bytes
14
22
  @http = HTTPX.with(
15
23
  headers: { 'X-API-Key' => token },
16
24
  timeout: {
@@ -22,25 +30,20 @@ module Hookd
22
30
 
23
31
  # Register one or more hooks
24
32
  # @param count [Integer, nil] number of hooks to register (default: 1)
33
+ # @param ttl [String, nil] lifetime as a Go duration ("168h") or day count
34
+ # ("7d"); a value above the server's ephemeral hook_ttl registers a durable
35
+ # long-lived hook. Omit for an ephemeral hook.
36
+ # @param metadata [Hash, nil] arbitrary data stored with the hook and echoed
37
+ # back on poll
25
38
  # @return [Hookd::Hook, Array<Hookd::Hook>] single hook or array of hooks
26
39
  # @raise [Hookd::AuthenticationError] if authentication fails
27
40
  # @raise [Hookd::ServerError] if server returns 5xx
28
41
  # @raise [Hookd::ConnectionError] if connection fails
29
42
  # @raise [ArgumentError] if count is invalid
30
- def register(count: nil)
31
- body = count.nil? ? nil : { count: count }
32
-
43
+ def register(count: nil, ttl: nil, metadata: nil)
33
44
  raise ArgumentError, 'count must be a positive integer' if count && (!count.is_a?(Integer) || count < 1)
34
45
 
35
- response = post('/register', body)
36
-
37
- # Single hook response (backward compatible)
38
- return Hook.from_hash(response) if response.key?('id')
39
-
40
- # Multiple hooks response
41
- return [] if response['hooks'].nil? || response['hooks'].empty?
42
-
43
- response['hooks'].map { |h| Hook.from_hash(h) }
46
+ parse_register_response(post('/register', register_body(count, ttl, metadata)))
44
47
  end
45
48
 
46
49
  # Poll for interactions on a hook
@@ -92,8 +95,45 @@ module Hookd
92
95
  get('/metrics')
93
96
  end
94
97
 
98
+ # List long-lived hooks that currently have pending interactions, so you can
99
+ # discover which of your long-lived hooks fired without polling each one.
100
+ # Drain the details with #poll. Returns an empty array when none have fired
101
+ # (or the server has long-lived hooks disabled).
102
+ # @return [Array<Hookd::HookActivity>]
103
+ # @raise [Hookd::AuthenticationError] if authentication fails
104
+ # @raise [Hookd::ServerError] if server returns 5xx
105
+ # @raise [Hookd::ConnectionError] if connection fails
106
+ def activity
107
+ response = get('/activity')
108
+
109
+ hooks = response['hooks']
110
+ return [] if hooks.nil? || hooks.empty? || !hooks.is_a?(Array)
111
+
112
+ hooks.map { |h| HookActivity.from_hash(h) }
113
+ rescue NoMethodError => e
114
+ raise Error, "Invalid response format: #{e.message}"
115
+ end
116
+
95
117
  private
96
118
 
119
+ def register_body(count, ttl, metadata)
120
+ body = {}
121
+ body[:count] = count unless count.nil?
122
+ body[:ttl] = ttl unless ttl.nil?
123
+ body[:metadata] = metadata unless metadata.nil?
124
+ body.empty? ? nil : body
125
+ end
126
+
127
+ def parse_register_response(response)
128
+ # Single hook response (backward compatible)
129
+ return Hook.from_hash(response) if response.key?('id')
130
+
131
+ # Multiple hooks response
132
+ return [] if response['hooks'].nil? || response['hooks'].empty?
133
+
134
+ response['hooks'].map { |h| Hook.from_hash(h) }
135
+ end
136
+
97
137
  def get(path)
98
138
  url = "#{@server}#{path}"
99
139
  response = @http.get(url)
@@ -138,24 +178,49 @@ module Hookd
138
178
  raise ConnectionError, "Connection failed: #{error.message}"
139
179
  end
140
180
 
141
- body = response.body.to_s
142
-
143
181
  case response.status
144
182
  when 200, 201
145
- raise Error, 'Empty response body from server' if body.nil? || body.empty?
146
-
147
- JSON.parse(body)
183
+ parse_body(response)
148
184
  when 401
149
- raise AuthenticationError, "Authentication failed: #{body}"
185
+ raise AuthenticationError, "Authentication failed: #{error_excerpt(response)}"
150
186
  when 404
151
- raise NotFoundError, "Resource not found: #{body}"
187
+ raise NotFoundError, "Resource not found: #{error_excerpt(response)}"
152
188
  when 500..599
153
- raise ServerError, "Server error (#{response.status}): #{body}"
189
+ raise ServerError, "Server error (#{response.status}): #{error_excerpt(response)}"
154
190
  else
155
- raise Error, "Unexpected response (#{response.status}): #{body}"
191
+ raise Error, "Unexpected response (#{response.status}): #{error_excerpt(response)}"
156
192
  end
193
+ end
194
+
195
+ def parse_body(response)
196
+ body = read_body(response, @max_response_bytes)
197
+ raise Error, 'Empty response body from server' if body.empty?
198
+
199
+ JSON.parse(body)
157
200
  rescue JSON::ParserError => e
158
201
  raise Error, "Invalid JSON response: #{e.message}"
159
202
  end
203
+
204
+ # Accumulates chunk by chunk and stops at the ceiling, raising unless
205
+ # truncate is set, in which case it returns the bounded slice.
206
+ def read_body(response, limit, truncate: false)
207
+ return response.body.to_s if limit <= 0
208
+
209
+ body = nil
210
+ response.body.each do |chunk|
211
+ # Seed from the first chunk to keep the payload's own encoding.
212
+ body = body ? body << chunk : chunk.dup
213
+ next if body.bytesize <= limit
214
+ return body.byteslice(0, limit).scrub if truncate
215
+
216
+ raise ResponseTooLargeError, "Response exceeds the #{limit} byte limit"
217
+ end
218
+ body || ''
219
+ end
220
+
221
+ # A large error page must not become a large exception message.
222
+ def error_excerpt(response)
223
+ read_body(response, ERROR_BODY_EXCERPT_BYTES, truncate: true)
224
+ end
160
225
  end
161
226
  end
data/lib/hookd/error.rb CHANGED
@@ -15,4 +15,7 @@ module Hookd
15
15
 
16
16
  # Raised when the server returns a 5xx error
17
17
  class ServerError < Error; end
18
+
19
+ # Raised when a response exceeds the client's size limit
20
+ class ResponseTooLargeError < Error; end
18
21
  end
data/lib/hookd/hook.rb CHANGED
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- # Represents a registered hook with DNS and HTTP endpoints
4
+ # Represents a registered hook with its endpoints. smtp is set only when the
5
+ # server runs a mail listener. expires_at and metadata are populated for
6
+ # long-lived hooks (registered with a ttl) and nil otherwise.
5
7
  class Hook
6
- attr_reader :id, :dns, :http, :https, :created_at
8
+ attr_reader :id, :dns, :http, :https, :smtp, :created_at, :expires_at, :metadata
7
9
 
8
- def initialize(id:, dns:, http:, https:, created_at:)
10
+ def initialize(id:, dns:, http:, https:, created_at:, smtp: nil, expires_at: nil, metadata: nil)
9
11
  @id = id
10
12
  @dns = dns
11
13
  @http = http
12
14
  @https = https
15
+ @smtp = smtp
13
16
  @created_at = created_at
17
+ @expires_at = expires_at
18
+ @metadata = metadata
14
19
  end
15
20
 
16
21
  # Create a Hook from API response hash
@@ -22,7 +27,10 @@ module Hookd
22
27
  dns: hash['dns'],
23
28
  http: hash['http'],
24
29
  https: hash['https'],
25
- created_at: hash['created_at']
30
+ smtp: hash['smtp'],
31
+ created_at: hash['created_at'],
32
+ expires_at: hash['expires_at'],
33
+ metadata: hash['metadata']
26
34
  )
27
35
  end
28
36
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hookd
4
+ # Summarises a long-lived hook that currently has pending interactions,
5
+ # returned by Client#activity.
6
+ class HookActivity
7
+ attr_reader :hook, :pending_count, :last_interaction_at
8
+
9
+ def initialize(hook:, pending_count:, last_interaction_at:)
10
+ @hook = hook
11
+ @pending_count = pending_count
12
+ @last_interaction_at = last_interaction_at
13
+ end
14
+
15
+ # Create a HookActivity from an API response hash
16
+ def self.from_hash(hash)
17
+ raise ArgumentError, "Invalid hash: expected Hash, got #{hash.class}" unless hash.is_a?(Hash)
18
+
19
+ new(
20
+ hook: Hook.from_hash(hash['hook']),
21
+ pending_count: hash['pending_count'],
22
+ last_interaction_at: hash['last_interaction_at']
23
+ )
24
+ end
25
+
26
+ def to_s
27
+ "#<Hookd::HookActivity hook=#{hook.id} pending=#{pending_count}>"
28
+ end
29
+ end
30
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- # Represents a captured DNS or HTTP interaction
4
+ # Represents a captured DNS, HTTP or SMTP interaction
5
5
  class Interaction
6
6
  attr_reader :type, :timestamp, :source_ip, :data
7
7
 
@@ -32,6 +32,11 @@ module Hookd
32
32
  type == 'http'
33
33
  end
34
34
 
35
+ # Check if this is an SMTP interaction
36
+ def smtp?
37
+ type == 'smtp'
38
+ end
39
+
35
40
  def to_s
36
41
  "#<Hookd::Interaction type=#{type} timestamp=#{timestamp} source_ip=#{source_ip}>"
37
42
  end
data/lib/hookd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- VERSION = '1.2.2'
4
+ VERSION = '1.4.0'
5
5
  end
data/lib/hookd.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'hookd/version'
4
4
  require_relative 'hookd/error'
5
5
  require_relative 'hookd/hook'
6
+ require_relative 'hookd/hook_activity'
6
7
  require_relative 'hookd/interaction'
7
8
  require_relative 'hookd/client'
8
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hookd-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua MARTINELLE
@@ -23,8 +23,8 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.0'
26
- description: Ruby client library for Hookd, a DNS/HTTP interaction server for security
27
- testing and debugging
26
+ description: Ruby client library for Hookd, a DNS/SMTP/HTTP interaction server for
27
+ security testing and debugging
28
28
  email:
29
29
  - contact@jomar.fr
30
30
  executables: []
@@ -36,6 +36,7 @@ files:
36
36
  - lib/hookd/client.rb
37
37
  - lib/hookd/error.rb
38
38
  - lib/hookd/hook.rb
39
+ - lib/hookd/hook_activity.rb
39
40
  - lib/hookd/interaction.rb
40
41
  - lib/hookd/version.rb
41
42
  homepage: https://github.com/JoshuaMart/Hookd