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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +10 -8
- data/docs/anthropic.md +28 -83
- data/docs/configuration.md +31 -5
- data/docs/elevenlabs.md +1 -1
- data/docs/help-center.md +4 -4
- data/docs/openai.md +63 -4
- data/docs/vertex.md +1 -0
- data/lib/coolhand/base_interceptor.rb +137 -37
- data/lib/coolhand/batch_body_cap.rb +36 -0
- data/lib/coolhand/body_redactor.rb +111 -0
- data/lib/coolhand/body_stream_tap.rb +121 -0
- data/lib/coolhand/default_intercept_addresses.yml +8 -0
- data/lib/coolhand/{public_content_service.rb → help_center_service.rb} +1 -1
- data/lib/coolhand/logger_service.rb +54 -5
- data/lib/coolhand/net_http_interceptor.rb +190 -28
- data/lib/coolhand/open_ai/batch_result_processor.rb +139 -53
- data/lib/coolhand/open_ai/webhook_id_store.rb +7 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +43 -28
- data/lib/coolhand/path_normalizer.rb +36 -0
- data/lib/coolhand/string_body_redactor.rb +83 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +2 -2
- data/lib/coolhand/webhook_interceptor.rb +23 -0
- data/lib/coolhand.rb +7 -4
- metadata +8 -3
|
@@ -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
|
-
{
|
|
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
|
|
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 =
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
216
|
+
BodyRedactor.redact(body)
|
|
217
|
+
end
|
|
127
218
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
next unless key
|
|
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 =
|
|
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
|