coolhand 0.10.0 → 0.10.1

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.
@@ -13,6 +13,28 @@ module Coolhand
13
13
  # paths (interceptor + webhook forwarding) stay consistent.
14
14
  SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
15
15
 
16
+ # Rate-limit telemetry headers (OpenAI `x-ratelimit-remaining-tokens`, Anthropic
17
+ # `anthropic-ratelimit-input-tokens-reset`, ...) contain the word "token" but carry counts and
18
+ # reset times, not credentials, so redacting them only loses useful data. The vocabulary after
19
+ # `ratelimit` is closed (a lookalike such as `x-ratelimit-api-key` stays redacted), and the
20
+ # optional vendor prefix captured in group 1 must itself be free of sensitive words (see
21
+ # `safe_header?`), so `secret-ratelimit-remaining` stays redacted too. Checked against the
22
+ # downcased header name.
23
+ SAFE_HEADER_PATTERN =
24
+ /\A((?:[a-z0-9]+-){0,2})rate-?limit(?:-(?:limit|remaining|reset|requests|tokens|input|output))+\z/
25
+
26
+ # True when a header name should be redacted. Shared with LoggerService so both logging paths
27
+ # make the same call.
28
+ def sensitive_header?(name)
29
+ down = name.to_s.downcase
30
+ down.match?(SENSITIVE_HEADER_PATTERN) && !safe_header?(down)
31
+ end
32
+
33
+ def safe_header?(down_name)
34
+ match = SAFE_HEADER_PATTERN.match(down_name)
35
+ !match.nil? && !match[1].match?(SENSITIVE_HEADER_PATTERN)
36
+ end
37
+
16
38
  def sanitize_headers(headers)
17
39
  return {} if headers.nil?
18
40
 
@@ -37,7 +59,7 @@ module Coolhand
37
59
  headers.each { |k, v| h[k.to_s] = normalize_header_value(v) }
38
60
  h
39
61
  else
40
- { "raw" => headers.to_s }
62
+ {} # fail closed: an unrecognised shape can't be checked header-by-header
41
63
  end
42
64
 
43
65
  raw ||= {} # in case to_hash raised and nothing was built
@@ -55,7 +77,7 @@ module Coolhand
55
77
  else
56
78
  "[REDACTED]"
57
79
  end
58
- elsif key_down.match?(SENSITIVE_HEADER_PATTERN)
80
+ elsif sensitive_header?(key_down)
59
81
  sanitized[k] = "[REDACTED]"
60
82
  end
61
83
  end
@@ -78,7 +100,39 @@ module Coolhand
78
100
  # credential params used by AWS (X-Amz-Signature, X-Amz-Credential,
79
101
  # X-Amz-Security-Token) and Google Cloud (X-Goog-Signature,
80
102
  # X-Goog-Credential) storage APIs.
81
- SENSITIVE_QUERY_PARAM_PATTERN = /key|token|secret|sig|credential|password|auth/i
103
+ SENSITIVE_QUERY_PARAM_PATTERN = Regexp.new(
104
+ %w[key token secret sig credential password auth jwt bearer session cookie passphrase ticket assertion].join("|"),
105
+ Regexp::IGNORECASE
106
+ )
107
+
108
+ # Param names that are sensitive but too short or generic to match as a substring without
109
+ # over-matching (`code` would hit `encoding`, `country_code`). Matched by whole name,
110
+ # case-insensitively: `code` (Azure Functions keys, OAuth authorization codes), `sas` (Azure
111
+ # SAS tokens), and `pwd`/`passwd`, which the `password` substring above does not cover.
112
+ SENSITIVE_QUERY_PARAM_EXACT_NAMES = %w[code sas pwd passwd pass sid otp].freeze
113
+
114
+ # Percent-encoded "[REDACTED]", the form the placeholder takes once inside a URL.
115
+ REDACTED_URL_VALUE = "%5BREDACTED%5D"
116
+
117
+ # A `;name=value` matrix parameter. In a path the value stops at the next path delimiter; in a
118
+ # query value "/" is legal inside a secret (base64), so the value runs to the next ";" or "&".
119
+ PATH_MATRIX_PARAM_PATTERN = %r{;([^/;?#=&]+)=([^/;?#&]*)}
120
+ QUERY_MATRIX_PARAM_PATTERN = /;([^;=&#]+)=([^;&#]*)/
121
+
122
+ def sensitive_query_param?(name)
123
+ decoded = begin
124
+ # scrub: a percent-escaped invalid byte (`%FF`) must not make the match below raise.
125
+ URI.decode_www_form_component(name).scrub
126
+ rescue ArgumentError
127
+ name.scrub
128
+ end
129
+ decoded.match?(SENSITIVE_QUERY_PARAM_PATTERN) || exact_sensitive_query_name?(decoded)
130
+ end
131
+
132
+ # Exact-name check that also sees through Rails/PHP bracket syntax: `code[]`, `auth[code]`.
133
+ def exact_sensitive_query_name?(decoded_name)
134
+ decoded_name.downcase.split(/[\[\]]+/).any? { |part| SENSITIVE_QUERY_PARAM_EXACT_NAMES.include?(part) }
135
+ end
82
136
 
83
137
  def sanitize_url(url)
84
138
  uri = URI.parse(url)
@@ -91,28 +145,57 @@ module Coolhand
91
145
  modified = true
92
146
  end
93
147
 
94
- if uri.query
95
- params = URI.decode_www_form(uri.query)
96
- redacted_query = false
97
- params.map! do |n, v|
98
- if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
99
- redacted_query = true
100
- [n, "[REDACTED]"]
101
- else
102
- [n, v]
103
- end
104
- end
105
- if redacted_query
106
- uri.query = URI.encode_www_form(params)
107
- modified = true
108
- end
148
+ path = uri.path && redact_matrix_params(uri.path, PATH_MATRIX_PARAM_PATTERN)
149
+ if path != uri.path
150
+ uri.path = path
151
+ modified = true
152
+ end
153
+
154
+ query = uri.query && redact_query(uri.query)
155
+ if query != uri.query
156
+ uri.query = query
157
+ modified = true
158
+ end
159
+
160
+ # A fragment never reaches a server, but a caller-supplied URL can carry one (OAuth
161
+ # implicit flow puts `access_token=...` there), so drop it as the fallback below does.
162
+ if uri.fragment
163
+ uri.fragment = nil
164
+ modified = true
109
165
  end
110
166
 
111
167
  modified ? uri.to_s : url
112
168
  rescue URI::InvalidURIError
113
169
  # Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
114
170
  # that could carry a credential (userinfo, query, fragment) rather than pass it through.
115
- url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
171
+ stripped = url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
172
+ redact_matrix_params(stripped, PATH_MATRIX_PARAM_PATTERN)
173
+ end
174
+
175
+ def redact_matrix_params(text, pattern)
176
+ text.gsub(pattern) do |match|
177
+ sensitive_query_param?(Regexp.last_match(1)) ? ";#{Regexp.last_match(1)}=#{REDACTED_URL_VALUE}" : match
178
+ end
179
+ end
180
+
181
+ # Works on the raw query rather than decode_www_form so a bare segment (`?sk-abc123`) is not
182
+ # mistaken for an empty-valued param, and untouched segments pass through byte-for-byte.
183
+ def redact_query(query)
184
+ query.split("&", -1).map { |segment| redact_query_segment(segment) }.join("&")
185
+ end
186
+
187
+ def redact_query_segment(segment)
188
+ return segment if segment.empty?
189
+
190
+ # Matrix redaction first: in `x;code=S` the first "=" makes the "name" `x;code`, which would
191
+ # dodge the exact-name rule.
192
+ segment = redact_matrix_params(segment, QUERY_MATRIX_PARAM_PATTERN)
193
+ name, separator, = segment.partition("=")
194
+ # No "=": nothing says what this is, so a stray token could be a credential. Fail closed.
195
+ return REDACTED_URL_VALUE if separator.empty?
196
+ return "#{name}=#{REDACTED_URL_VALUE}" if sensitive_query_param?(name)
197
+
198
+ segment
116
199
  end
117
200
 
118
201
  # Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
@@ -122,32 +205,46 @@ module Coolhand
122
205
  # (Elasticsearch) are caught — not just exact or snake_case-only names.
123
206
  SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
124
207
 
208
+ REDACTED = "[REDACTED]"
209
+
210
+ # Subtrees nested deeper than this are replaced wholesale rather than walked, so a pathological
211
+ # body can neither overflow the stack (SystemStackError is not a StandardError, so
212
+ # send_complete_request_log's rescue would not catch it) nor smuggle a credential past the walk.
213
+ MAX_BODY_DEPTH = 100
214
+
125
215
  def sanitize_body(body)
126
- return body unless body.is_a?(Hash)
216
+ BodyRedactor.redact(body)
217
+ end
127
218
 
128
- sanitized = body.dup
129
- sanitized.each_key do |key|
130
- next unless key.to_s.delete("_-").casecmp?("datasources")
219
+ def redact_sensitive_body_values(node, depth = 0)
220
+ return REDACTED if depth > MAX_BODY_DEPTH
131
221
 
132
- sanitized[key] = redact_sensitive_body_values(sanitized[key])
222
+ case node
223
+ when Hash then redact_sensitive_body_hash(node, depth)
224
+ when Array then node.map { |v| redact_sensitive_body_values(v, depth + 1) }
225
+ when String then redact_embedded_url_credentials(node)
226
+ else node
133
227
  end
134
- sanitized
135
228
  end
136
229
 
137
- def redact_sensitive_body_values(node)
138
- case node
139
- when Hash
140
- node.each_with_object({}) do |(k, v), acc|
141
- normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
142
- acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? "[REDACTED]" : redact_sensitive_body_values(v)
143
- end
144
- when Array
145
- node.map { |v| redact_sensitive_body_values(v) }
146
- else
147
- node
230
+ def redact_sensitive_body_hash(hash, depth)
231
+ hash.each_with_object({}) do |(k, v), acc|
232
+ normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
233
+ acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? REDACTED : redact_sensitive_body_values(v, depth + 1)
148
234
  end
149
235
  end
150
236
 
237
+ # A single whitespace-free absolute URL, e.g. an Azure `endpoint` of `https://u:PASS@h/?sas=...`.
238
+ # Only applied to data_sources config and to the exact URL-valued body keys in BodyRedactor
239
+ # (never free text), where a URL value can carry userinfo or a SAS/key query param itself.
240
+ EMBEDDED_URL_PATTERN = %r{\A[a-z][a-z0-9+.-]*://\S+\z}i
241
+
242
+ def redact_embedded_url_credentials(value)
243
+ # JSON.parse accepts invalid UTF-8, which would make the regexp raise and drop the whole log.
244
+ value = value.scrub unless value.valid_encoding?
245
+ value.match?(EMBEDDED_URL_PATTERN) ? sanitize_url(value) : value
246
+ end
247
+
151
248
  def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
152
249
  response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:, source_api: nil, model: nil,
153
250
  declared: nil)
@@ -159,7 +256,7 @@ module Coolhand
159
256
  headers: sanitize_headers(request_headers),
160
257
  request_body: sanitize_body(request_body),
161
258
  response_headers: sanitize_headers(response_headers),
162
- response_body: response_body,
259
+ response_body: sanitize_body(response_body),
163
260
  status_code: status_code,
164
261
  duration_ms: duration_ms,
165
262
  completed_at: end_time.iso8601,
@@ -181,6 +278,9 @@ module Coolhand
181
278
 
182
279
  def parse_json(string)
183
280
  JSON.parse(string)
281
+ rescue JSON::NestingError
282
+ # Too deep to walk for redaction; logging the raw string would skip BodyRedactor entirely.
283
+ { "_coolhand_capture_skipped" => "nesting_too_deep" }
184
284
  rescue JSON::ParserError, TypeError
185
285
  string
186
286
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Applies max_captured_body_bytes to the request/response bodies of batch items, the same way
5
+ # NetHttpInterceptor does for live requests: an oversized body is replaced by the body_too_large
6
+ # marker so the log entry (and the queued job holding it) stays small.
7
+ module BatchBodyCap
8
+ module_function
9
+
10
+ # Re-serializing a parsed number can write more bytes than the source had (depending on the json
11
+ # version, "1e9" becomes "1000000000.0" and "7e22" becomes "7.0000000000000004e+22"). Counting
12
+ # the separators around them, a body grows by well under this factor.
13
+ SERIALIZED_GROWTH = 6
14
+
15
+ # Returns the body, or the marker when its JSON size is over the cap. `source_size` is the byte
16
+ # size of the JSON the body was parsed from, when the caller has it (e.g. a JSONL line): a body
17
+ # that small even after SERIALIZED_GROWTH is within the cap without serializing it to measure.
18
+ def cap(body, source_size: nil)
19
+ return body if body.nil? || helpers.capture_limit.nil?
20
+ return body if source_size && !helpers.over_capture_limit?(source_size * SERIALIZED_GROWTH)
21
+
22
+ size = JSON.generate(body).bytesize
23
+ helpers.over_capture_limit?(size) ? helpers.body_too_large_marker(nil, size) : body
24
+ rescue JSON::NestingError
25
+ helpers.skipped_capture_marker(nil, "nesting_too_deep")
26
+ rescue JSON::JSONError
27
+ # NaN/Infinity or invalid UTF-8: it can't be logged either, and must not fail the other
28
+ # items of the batch.
29
+ helpers.skipped_capture_marker(nil, "unserializable_body")
30
+ end
31
+
32
+ def helpers
33
+ NetHttpInterceptor::Helpers
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Redacts credential-bearing values from request and response bodies before they are sent to
5
+ # Coolhand. Header and URL redaction live in BaseInterceptor; this covers the body.
6
+ module BodyRedactor
7
+ module_function
8
+
9
+ # Body keys whose *string* value is a credential in any request or response body, matched
10
+ # exactly (never as a substring) against the key lowercased with `_`, `-` and spaces removed,
11
+ # so `api_key`, `apiKey`, `API-Key` and `apikey` all match. Only String leaves are redacted:
12
+ # a JSON Schema property named `api_key` has a Hash value and is left alone, as is a numeric
13
+ # `usage.total_tokens`. Covers OpenAI Responses/Realtime MCP `tools[].authorization`, Anthropic
14
+ # `mcp_servers[].authorization_token`, and the legacy Realtime `client_secret` string form. Also
15
+ # covers `x-api-key`, AWS `aws_secret_access_key`/`secret_access_key`, `session_token`,
16
+ # `bearer_token` and any String under a generic `secret` key.
17
+ CREDENTIAL_BODY_KEYS = %w[
18
+ authorization authorizationtoken accesstoken refreshtoken idtoken
19
+ apikey clientsecret privatekey secretkey password
20
+ xapikey secret secretaccesskey awssecretaccesskey sessiontoken bearertoken
21
+ ].freeze
22
+
23
+ # Body keys whose *string* value is a URL that can carry credentials (MCP `server_url`, a
24
+ # presigned `image_url`/`file_url`, a datastore `endpoint`). Matched exactly like
25
+ # CREDENTIAL_BODY_KEYS, but the value is scrubbed with BaseInterceptor.sanitize_url instead of
26
+ # replaced, and only when it is a single absolute URL: prose that merely contains one is never
27
+ # touched. A bare `token` key is deliberately absent from both lists (logprobs use `{"token":"Hello"}`).
28
+ URL_BODY_KEYS = %w[url serverurl imageurl fileurl endpoint].freeze
29
+
30
+ def redact(body)
31
+ return StringBodyRedactor.redact(body) if body.is_a?(String)
32
+ return body unless body.is_a?(Hash) || body.is_a?(Array)
33
+
34
+ redact_body_node(body, 0)
35
+ end
36
+
37
+ def redact_body_node(node, depth, secret_object: false)
38
+ return BaseInterceptor::REDACTED if depth > BaseInterceptor::MAX_BODY_DEPTH
39
+
40
+ case node
41
+ when Hash then redact_body_hash(node, depth, secret_object: secret_object)
42
+ when Array then node.map { |v| redact_body_node(v, depth + 1) }
43
+ else node
44
+ end
45
+ end
46
+
47
+ # `secret_object` marks the object under a `client_secret` key (legacy Realtime
48
+ # `{value, expires_at}`), whose `value` is the ephemeral key.
49
+ def redact_body_hash(hash, depth, secret_object: false)
50
+ context = { secret_value: secret_object || ephemeral_secret?(hash), mcp: mcp_tool?(hash) }
51
+ hash.each_with_object({}) do |(key, value), acc|
52
+ acc[key] = redact_body_entry(normalize_body_key(key), value, depth, context)
53
+ end
54
+ end
55
+
56
+ def redact_body_entry(name, value, depth, context)
57
+ if name == "datasources"
58
+ BaseInterceptor.redact_sensitive_body_values(value, depth + 1)
59
+ elsif value.is_a?(String)
60
+ redact_string_value(name, value, context)
61
+ elsif name == "headers" && context[:mcp]
62
+ redact_header_values(value, depth + 1)
63
+ elsif name == "clientsecret" && value.is_a?(Hash)
64
+ redact_body_node(value, depth + 1, secret_object: true)
65
+ else
66
+ redact_body_node(value, depth + 1)
67
+ end
68
+ end
69
+
70
+ def redact_string_value(name, value, context)
71
+ return BaseInterceptor::REDACTED if redact_string_leaf?(name, context)
72
+
73
+ URL_BODY_KEYS.include?(name) ? BaseInterceptor.redact_embedded_url_credentials(value) : value
74
+ end
75
+
76
+ def redact_string_leaf?(name, context)
77
+ CREDENTIAL_BODY_KEYS.include?(name) || (context[:secret_value] && name == "value")
78
+ end
79
+
80
+ def normalize_body_key(key)
81
+ key.to_s.delete("_ -").downcase # trailing "-" is a literal hyphen, not a range
82
+ end
83
+
84
+ # Realtime GA `POST /v1/realtime/client_secrets` returns `{"value": "ek_...", "expires_at": 123,
85
+ # "session": {...}}`. `value` alone is far too generic to redact by name, so require the
86
+ # numeric `expires_at` that accompanies it.
87
+ def ephemeral_secret?(hash)
88
+ hash_value(hash, "value").is_a?(String) && hash_value(hash, "expires_at").is_a?(Numeric)
89
+ end
90
+
91
+ # An OpenAI remote-MCP tool (`type: "mcp"`, or any entry carrying a `server_url`), whose
92
+ # `headers` map holds authentication headers. A bare `headers` key elsewhere is left alone.
93
+ def mcp_tool?(hash)
94
+ hash_value(hash, "type") == "mcp" || !hash_value(hash, "server_url").nil?
95
+ end
96
+
97
+ # String values are the credentials; anything else (a malformed nested shape) goes back through
98
+ # the generic walk so it is still depth-limited and credential-key checked.
99
+ def redact_header_values(headers, depth)
100
+ return redact_body_node(headers, depth) unless headers.is_a?(Hash)
101
+
102
+ headers.transform_values do |v|
103
+ v.is_a?(String) ? BaseInterceptor::REDACTED : redact_body_node(v, depth + 1)
104
+ end
105
+ end
106
+
107
+ def hash_value(hash, name)
108
+ hash.fetch(name) { hash[name.to_sym] }
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module NetHttpInterceptor
5
+ # Stands in for a request's body_stream while Net::HTTP sends it, copying the bytes that pass
6
+ # through into a buffer capped at max_captured_body_bytes. The host's stream is read exactly
7
+ # once, by Net::HTTP, in the same chunks as without the gem; nothing is read ahead, buffered in
8
+ # full or rewound. Past the cap the buffer is dropped and only a byte count is kept, so memory
9
+ # stays bounded however large the upload is.
10
+ #
11
+ # Deliberately exposes nothing but #read (and #readpartial, only when the source has it): a
12
+ # delegating wrapper would also expose #to_io/#to_path, which IO.copy_stream uses to read the
13
+ # underlying file directly, bypassing the tap.
14
+ class BodyStreamTap
15
+ attr_reader :source, :bytes_seen
16
+
17
+ # IO.copy_stream also accepts a path (String) or a Pathname as its source, reading the file
18
+ # itself; the tap only offers #read, which those don't support, so they are left untouched.
19
+ def self.tappable?(stream)
20
+ stream.respond_to?(:read) && !stream.is_a?(String) &&
21
+ (stream.respond_to?(:to_io) || !stream.respond_to?(:to_path))
22
+ end
23
+
24
+ def self.install(req)
25
+ tap = new(req.body_stream)
26
+ req.body_stream = tap
27
+ tap
28
+ end
29
+
30
+ def initialize(source)
31
+ @source = source
32
+ @buffer = +"".b
33
+ @bytes_seen = 0
34
+ @eof = false
35
+ @failed = false
36
+ define_singleton_method(:readpartial) { |*args| read_partial(*args) } if source.respond_to?(:readpartial)
37
+ end
38
+
39
+ def read(*args)
40
+ data = @source.read(*args)
41
+ record(data, finished: end_of_stream?(args, data))
42
+ data
43
+ end
44
+
45
+ # Whether the source has been read to its end (not IO#eof?: false until a read hits the end).
46
+ def reached_end?
47
+ @eof
48
+ end
49
+
50
+ # The capture itself failed (not the host's stream); the request body is simply not logged.
51
+ def failed?
52
+ @failed
53
+ end
54
+
55
+ def overflowed?
56
+ @buffer.nil? && !@failed
57
+ end
58
+
59
+ def body
60
+ @buffer&.dup&.force_encoding(Encoding::UTF_8)
61
+ end
62
+
63
+ # Puts the host's own stream back, unless something else has replaced the tap since.
64
+ def restore_stream(req)
65
+ req.body_stream = @source if req.body_stream.equal?(self)
66
+ end
67
+
68
+ # The request body for the log once the request has been attempted. Over the cap is known
69
+ # from the bytes seen so far, even if the stream was not fully sent. Otherwise a stream that
70
+ # never reached its end (the request failed first) has no complete body.
71
+ def body_for_log(req)
72
+ return if failed?
73
+ return helpers.body_too_large_marker(req, bytes_seen) if overflowed?
74
+ return incomplete_marker(req) unless reached_end?
75
+
76
+ BaseInterceptor.parse_json(body)
77
+ end
78
+
79
+ private
80
+
81
+ # Reading everything, or getting nothing back from a read for a positive length (IO.copy_stream
82
+ # also treats an empty String there as the end).
83
+ def end_of_stream?(args, data)
84
+ length = args.first
85
+ length.nil? || data.nil? || (length.positive? && data.empty?)
86
+ end
87
+
88
+ def helpers
89
+ NetHttpInterceptor::Helpers
90
+ end
91
+
92
+ def incomplete_marker(req)
93
+ helpers.skipped_capture_marker(req, "body_stream_incomplete", size_bytes: bytes_seen)
94
+ end
95
+
96
+ def read_partial(*args)
97
+ data = @source.readpartial(*args)
98
+ record(data)
99
+ data
100
+ rescue EOFError
101
+ @eof = true
102
+ raise
103
+ end
104
+
105
+ # Runs inside the host's request send: whatever goes wrong here must never reach the host.
106
+ def record(data, finished: false)
107
+ @eof ||= finished
108
+ return unless data.is_a?(String)
109
+
110
+ @bytes_seen += data.bytesize
111
+ return unless @buffer
112
+
113
+ @buffer = helpers.over_capture_limit?(@bytes_seen) ? nil : @buffer << data.b
114
+ rescue StandardError => e
115
+ @failed = true
116
+ @buffer = nil
117
+ Coolhand.log "❌ Error capturing request body_stream: #{e.message}"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -23,7 +23,15 @@
23
23
  - "models.inference.ai.azure.com"
24
24
  - "aiplatform.googleapis.com"
25
25
  - "gateway.ai.cloudflare.com"
26
+ # Amazon Bedrock. Every endpoint variant needs its own entry: a wildcard entry is
27
+ # an exact host match with no implicit subdomains, and "amazonaws.com.cn" is not a
28
+ # dot-boundary suffix of "amazonaws.com". "api.aws" hosts are the dual-stack (IPv6)
29
+ # endpoints; "-fips" hosts are the FIPS 140-3 ones, used by GovCloud callers.
26
30
  - "bedrock-runtime.*.amazonaws.com"
31
+ - "bedrock-runtime.*.amazonaws.com.cn"
32
+ - "bedrock-runtime-fips.*.amazonaws.com"
33
+ - "bedrock-runtime.*.api.aws"
34
+ - "bedrock-runtime-fips.*.api.aws"
27
35
  - "api.deepseek.com"
28
36
  - "api.mistral.ai"
29
37
  - "api.perplexity.ai"
@@ -15,7 +15,7 @@ module Coolhand
15
15
  # Unlike every other service this one needs no API key. A configured `api_key` is sent as `X-API-Key` and lifts
16
16
  # the anonymous limit of 30 requests per minute per IP (429 beyond); with none, no key header is sent. A key the
17
17
  # server rejects raises a 401 {Coolhand::HttpError}; it is not retried anonymously. See docs/help-center.md.
18
- class PublicContentService < ApiService
18
+ class HelpCenterService < ApiService
19
19
  include ReadQuery
20
20
 
21
21
  ERROR_NOUN = "Help center"
@@ -4,6 +4,25 @@ require_relative "api_service"
4
4
 
5
5
  module Coolhand
6
6
  class LoggerService < ApiService
7
+ # CGI/Rack/Rails/app-server variables that show up in a Rails `request.headers` iteration
8
+ # (it walks the whole Rack env) but are not HTTP headers. CONTENT_TYPE and
9
+ # CONTENT_LENGTH are real headers and are deliberately absent. ORIGINAL_FULLPATH and
10
+ # REQUEST_PATH carry the query string, so they must never be forwarded. HTTP_PROXY is the
11
+ # process env var (it may embed credentials), never a client header (the "httpoxy" case).
12
+ RACK_ENV_CGI_KEYS = %w[
13
+ AUTH_TYPE GATEWAY_INTERFACE HTTPS HTTP_PROXY HTTP_VERSION ORIGINAL_FULLPATH ORIGINAL_SCRIPT_NAME
14
+ PATH_INFO PATH_TRANSLATED QUERY_STRING REMOTE_ADDR REMOTE_HOST REMOTE_IDENT REMOTE_PORT
15
+ REMOTE_USER REQUEST_METHOD REQUEST_PATH REQUEST_URI SCRIPT_NAME SERVER_ADDR SERVER_NAME
16
+ SERVER_PORT SERVER_PROTOCOL SERVER_SOFTWARE
17
+ ].freeze
18
+
19
+ # CGI-style env key: uppercase letters, digits and underscores (REQUEST_METHOD, DATABASE_URL).
20
+ CGI_ENV_KEY_PATTERN = /\A[A-Z0-9_]+\z/
21
+ # Lowercase dotted Rack/Rails/server key: rack.session, action_dispatch.request.parameters, puma.config.
22
+ RACK_DOTTED_KEY_PATTERN = /\A[a-z0-9_]+(?:\.[a-z0-9_]+)+\z/
23
+ RACK_ENV_HEADER_KEYS = %w[CONTENT_TYPE CONTENT_LENGTH].freeze
24
+ private_constant :CGI_ENV_KEY_PATTERN, :RACK_DOTTED_KEY_PATTERN, :RACK_ENV_HEADER_KEYS
25
+
7
26
  def initialize
8
27
  super("v2/llm_request_logs")
9
28
  end
@@ -26,8 +45,8 @@ module Coolhand
26
45
  method: options[:method] || "POST",
27
46
  url: BaseInterceptor.sanitize_url(options[:url] || build_webhook_url(source, event_type)),
28
47
  headers: sanitize_headers(headers),
29
- request_body: clean_webhook_body(webhook_body, source),
30
- response_body: options[:response_body],
48
+ request_body: BodyRedactor.redact(clean_webhook_body(webhook_body, source)),
49
+ response_body: BodyRedactor.redact(options[:response_body]),
31
50
  response_headers: options[:response_headers] && sanitize_headers(options[:response_headers]),
32
51
  status_code: options[:status_code] || 200,
33
52
  source: "#{source}_webhook"
@@ -58,21 +77,51 @@ module Coolhand
58
77
  return {} unless headers.respond_to?(:each)
59
78
 
60
79
  # Handle Rails request headers or plain hash
80
+ entries = headers.enum_for(:each).to_a
81
+ rack_env = rack_env_shaped?(entries)
82
+
61
83
  clean_headers = {}
62
- headers.each do |key, value|
63
- next unless key && value
84
+ entries.each do |key, value|
85
+ next unless forwardable_header?(key, value, rack_env: rack_env)
64
86
 
65
87
  # Convert Rails HTTP_ prefix headers
66
88
  clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
67
89
 
68
90
  # Redact sensitive headers (shared with BaseInterceptor so both logging
69
91
  # paths treat the same header names as sensitive)
70
- clean_value = clean_key.match?(BaseInterceptor::SENSITIVE_HEADER_PATTERN) ? "[REDACTED]" : value.to_s
92
+ clean_value = BaseInterceptor.sensitive_header?(clean_key) ? "[REDACTED]" : value.to_s
71
93
  clean_headers[clean_key] = clean_value
72
94
  end
73
95
  clean_headers
74
96
  end
75
97
 
98
+ # Rails' request.headers#each yields the whole Rack env, not just headers. Drop the
99
+ # non-header entries (rack.session, action_dispatch.*, REQUEST_METHOD, QUERY_STRING, ...)
100
+ # so session/params data is never forwarded. Ordinary header names (including normalized
101
+ # plain-Hash ones like "Content-Type" or "x-webhook-id") never match the denylist.
102
+ # CGI/FastCGI handlers also put the process env (DATABASE_URL, REDIS_URL, ...) in the Rack
103
+ # env, so once the input looks like one, only HTTP_*, CONTENT_TYPE and CONTENT_LENGTH pass
104
+ # (a process env var that itself starts with HTTP_ still gets through; HTTP_PROXY is denied).
105
+ def forwardable_header?(key, value, rack_env: false)
106
+ return false unless key && value
107
+
108
+ name = key.to_s
109
+ return false if name.include?(".") || RACK_ENV_CGI_KEYS.include?(name)
110
+
111
+ !rack_env || name.start_with?("HTTP_") || RACK_ENV_HEADER_KEYS.include?(name)
112
+ end
113
+
114
+ # A Rack env is recognised by a positive signal the Rack spec guarantees (REQUEST_METHOD, or
115
+ # a lowercase dotted key like rack.input), or by every key being env-shaped. Requiring only
116
+ # the latter would let a single lowercase key (Warden's "warden", a shell's http_proxy)
117
+ # switch the allowlist off for the whole env. Mixed-case dotted names ("X.Trace") are
118
+ # valid header names, so they are not a signal.
119
+ def rack_env_shaped?(entries)
120
+ keys = entries.filter_map { |key, _value| key&.to_s }
121
+ keys.any? { |name| name == "REQUEST_METHOD" || name.match?(RACK_DOTTED_KEY_PATTERN) } ||
122
+ (keys.any? && keys.all? { |name| name.match?(CGI_ENV_KEY_PATTERN) })
123
+ end
124
+
76
125
  def clean_webhook_body(body, source)
77
126
  # Service-specific binary field filters
78
127
  binary_fields = case source.to_s.downcase