coolhand 0.9.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 +40 -0
- data/README.md +28 -7
- data/docs/anthropic.md +28 -83
- data/docs/configuration.md +31 -5
- data/docs/elevenlabs.md +1 -1
- data/docs/help-center.md +120 -0
- 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/help_center_service.rb +69 -0
- data/lib/coolhand/logger_service.rb +55 -6
- 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/read_requests.rb +11 -2
- 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 +10 -0
- metadata +9 -2
data/docs/openai.md
CHANGED
|
@@ -12,7 +12,7 @@ Requires Rails — `Coolhand::WebhookInterceptor` and `Coolhand::OpenAi::BatchRe
|
|
|
12
12
|
- Include the interceptor in your controller: `include Coolhand::WebhookInterceptor`
|
|
13
13
|
- Add the `before_action` to validate and populate the `@validator` payload: `before_action :intercept_batch_request, only: :openai`
|
|
14
14
|
- Ensure you skip CSRF for the webhook endpoint: `skip_before_action :verify_authenticity_token`
|
|
15
|
-
- Override the `webhook_secret` method to return your OpenAI webhook secret
|
|
15
|
+
- Override the `webhook_secret` method to return your OpenAI webhook secret. Define it under `private` so it can't be routed as an action
|
|
16
16
|
|
|
17
17
|
`intercept_batch_request` already validates the webhook and, for `batch.completed`/`batch.failed`/`batch.expired`/`batch.cancelled` events, calls `Coolhand::OpenAi::BatchResultProcessor` — that part requires no code in your controller action. The batch's status is checked inline in the `before_action`, but for a completed batch the two JSONL file downloads and the one-request-log-per-item sends run on Coolhand's [background log thread](configuration.md#asynchronous-logging), so a large batch doesn't hold up your webhook response. (With `config.async_logging = false` this all runs inline, and a large batch can trip your webhook endpoint's timeout and OpenAI's retry behavior.) The example below shows a controller action doing additional, app-specific work on top of that (e.g. updating your own `BatchApiRequest` record and enqueuing your own background job), which is optional.
|
|
18
18
|
|
|
@@ -53,19 +53,68 @@ rescue StandardError => e
|
|
|
53
53
|
head :internal_server_error
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
private # keep helpers like this below your public actions
|
|
57
|
+
|
|
56
58
|
def webhook_secret
|
|
57
59
|
Rails.application.credentials.openai_webhook_secret
|
|
58
60
|
end
|
|
59
61
|
# ...existing code...
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
`intercept_batch_request`, `webhook_secret` and `process_event` are private in `Coolhand::WebhookInterceptor`, so Rails doesn't treat them as controller actions, and `before_action :intercept_batch_request` works with a private method. Code outside the controller can no longer call them directly (it gets `NoMethodError`); `super` from your own override still works.
|
|
65
|
+
|
|
66
|
+
If your controller re-defines `webhook_secret` or `process_event` as public, they are public methods again, but `Coolhand::WebhookInterceptor` still removes all three names from the controller's `action_methods`, so they stay unroutable. That also means these names are reserved: a controller action with one of those names would not be routable. Defining your overrides under `private` is still the recommended form.
|
|
67
|
+
|
|
68
|
+
## What gets logged for a completed batch
|
|
69
|
+
|
|
70
|
+
Each batch item is logged with `source_api: "openai"` and the `model` from the item's request body (falling back to the response body). Batch input files use relative URLs such as `/v1/chat/completions`; these are logged as `https://api.openai.com/v1/chat/completions` (the host is not configurable, so Azure OpenAI or proxy batches are logged with that host too). The batch's `in_progress_at` and `completed_at` timestamps become each log's start and completion times; if OpenAI reports either as missing, the whole batch is skipped with an error in `Rails.logger` rather than logged with bogus times. If `completed_at` is earlier than `in_progress_at`, a warning is logged and `duration_ms` is clamped to 0.
|
|
71
|
+
|
|
72
|
+
Each item's request body and response body are capped separately by [`max_captured_body_bytes`](configuration.md#body-capture-limits): one over the limit is logged as a `{"_coolhand_capture_skipped" => "body_too_large", ...}` placeholder (the item itself is still logged, and `model` is still taken from the full request body). See [Body Capture Limits](configuration.md#body-capture-limits) for the other placeholders. When the OpenAI client returns a result file as a String or IO, it is read and parsed one JSONL line at a time; a client that returns the file already parsed (`ruby-openai` does) has loaded it in full before Coolhand sees it. A malformed line is skipped and counted in an error log, and the other lines are still processed.
|
|
73
|
+
|
|
74
|
+
The batch ID from the webhook payload and the batch's input/output file IDs are used in calls to the OpenAI API with your `OPENAI_API_KEY`, so they are format-checked first (`batch_...` and `file-...`/`file_...`, letters, digits, `_` and `-` only). An event or batch whose IDs don't match is ignored with an error in `Rails.logger`.
|
|
75
|
+
|
|
62
76
|
## Signature header and secret rotation
|
|
63
77
|
|
|
64
78
|
The `webhook-signature` header may carry several space-separated signatures (`v1,sigA v1,sigB`), as senders produce while a webhook secret is being rotated. `Coolhand::OpenAi::WebhookValidator` accepts the request if any `v1,` entry matches your secret, comparing each in constant time and ignoring entries with any other version prefix. The timestamp and replay checks below still run, and only after a signature matches.
|
|
65
79
|
|
|
80
|
+
## Local development and testing
|
|
81
|
+
|
|
82
|
+
Signature headers (`webhook-signature`, `webhook-timestamp`, `webhook-id`) are required whenever a webhook secret is configured, in every `Rails.env`, including `development` and `test`. A request missing any of them is rejected, so your controller responds with 401. Earlier versions let unsigned requests through in development and test, which meant an app running with `RAILS_ENV=development` on a reachable host accepted forged webhooks.
|
|
83
|
+
|
|
84
|
+
The same applies to a missing (`nil`) request body: with a secret configured it is rejected in every `Rails.env`, since there is nothing to verify the signature against. An empty-string body is not treated as missing; it goes through the normal signature check and passes only if it was actually signed.
|
|
85
|
+
|
|
86
|
+
Signature verification (and the missing-body check) is skipped, with a logged warning, only when no secret is configured at all, and only in `development` and `test`. Every other environment rejects the request.
|
|
87
|
+
|
|
88
|
+
If you post unsigned test payloads to a local app that has a secret set, pick one of these:
|
|
89
|
+
|
|
90
|
+
- **Sign the test payload** with the same secret:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
require "openssl"
|
|
94
|
+
require "base64"
|
|
95
|
+
require "json"
|
|
96
|
+
require "securerandom"
|
|
97
|
+
|
|
98
|
+
# Same key handling as the validator: a `whsec_` secret is base64-decoded, anything else is used as-is.
|
|
99
|
+
secret = ENV.fetch("OPENAI_WEBHOOK_SECRET")
|
|
100
|
+
secret_bytes = secret.start_with?("whsec_") ? Base64.strict_decode64(secret.delete_prefix("whsec_")) : secret
|
|
101
|
+
id = "wh_local_#{SecureRandom.hex(4)}"
|
|
102
|
+
timestamp = Time.now.to_i.to_s
|
|
103
|
+
body = { type: "batch.completed", data: { id: "batch_123" } }.to_json
|
|
104
|
+
signature = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret_bytes, "#{id}.#{timestamp}.#{body}"))
|
|
105
|
+
|
|
106
|
+
# Send `body` with these headers:
|
|
107
|
+
# webhook-id: <id>
|
|
108
|
+
# webhook-timestamp: <timestamp>
|
|
109
|
+
# webhook-signature: v1,<signature>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **Leave the secret unset in development**, for example have `webhook_secret` return `nil` unless `Rails.env.production?`.
|
|
113
|
+
- **Replay a real delivery** to your local app through a tunnel.
|
|
114
|
+
|
|
66
115
|
## Replay protection
|
|
67
116
|
|
|
68
|
-
`Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within that window — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
|
|
117
|
+
`Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within twice that window (a request dated at the edge of the tolerance stays fresh for up to two tolerances, so the id is remembered at least as long) — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
|
|
69
118
|
|
|
70
119
|
Both are configurable:
|
|
71
120
|
|
|
@@ -76,7 +125,7 @@ Coolhand.configure do |config|
|
|
|
76
125
|
end
|
|
77
126
|
```
|
|
78
127
|
|
|
79
|
-
The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the
|
|
128
|
+
The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the replay-protection window aren't caught by default. If you run more than one process, supply your own store via `config.webhook_id_store`; any object responding to `claim!(id, ttl_seconds)` works (`ttl_seconds` is twice `webhook_replay_tolerance_seconds`), e.g. one backed by `Rails.cache`. `claim!` must atomically check-and-record the id in one operation (not a separate read then write) — otherwise two requests racing on the same id can both "win" — so lean on your cache backend's compare-and-set primitive (`unless_exist:` below maps to Redis `SET NX` when using `RedisCacheStore`):
|
|
80
129
|
|
|
81
130
|
```ruby
|
|
82
131
|
class RailsCacheWebhookIdStore
|
|
@@ -92,4 +141,14 @@ Coolhand.configure do |config|
|
|
|
92
141
|
end
|
|
93
142
|
```
|
|
94
143
|
|
|
95
|
-
Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream
|
|
144
|
+
Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream raises (e.g. an error in your own `process_event` override, or an unexpectedly-shaped payload), the interceptor releases the claim before responding, so OpenAI's retry of the same `webhook-id` is processed rather than rejected as a replay. (`Coolhand::OpenAi::BatchResultProcessor` rescues and logs its own errors, and does its heavy work in the background, so this mainly matters for exceptions raised in your own code.)
|
|
145
|
+
|
|
146
|
+
Releasing is optional for custom stores: if your store also responds to `release!(id)`, it is called to forget the claim; a store that only implements `claim!` behaves as before and keeps the id claimed for its TTL even after a failure. To opt in, add `release!` to the store above:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class RailsCacheWebhookIdStore
|
|
150
|
+
def release!(id)
|
|
151
|
+
Rails.cache.delete("coolhand:openai_webhook:#{id}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
data/docs/vertex.md
CHANGED
|
@@ -19,6 +19,7 @@ require "coolhand/vertex/batch_result_processor"
|
|
|
19
19
|
- Optionally pass `model:` to populate the logged `model` field. It falls back to a `"model"` key in `batch_info` if present, and is omitted from the payload entirely when neither is available. If the value (from either source) looks like a Vertex model resource path (`publishers/<publisher>/models/<id>` or `projects/<project>/locations/<location>/models/<id>`), it's normalized down to just the trailing id/version before being logged; any other string is sent as-is.
|
|
20
20
|
- `batch_info["name"]` must be the exact Vertex job resource name (`projects/<project>/locations/<location>/batchPredictionJobs/<job-id>`), and `batch_info["startTime"]`/`["endTime"]` must be valid ISO 8601 timestamps — a batch with a missing or malformed resource name or timestamps is skipped entirely (logged as an error) rather than sent with a broken URL.
|
|
21
21
|
- If `endTime` precedes `startTime` (e.g. clock skew), the batch is still logged — `duration_ms` is clamped to `0` and a warning is logged — rather than dropping the results.
|
|
22
|
+
- Each item's `"request"` and `"response"` are capped separately by [`max_captured_body_bytes`](configuration.md#body-capture-limits): one over the limit is logged as a `{"_coolhand_capture_skipped" => "body_too_large", ...}` placeholder. (See [Body Capture Limits](configuration.md#body-capture-limits) for the other placeholders.) The results array is supplied by your code, so it is already in memory; only what is sent to Coolhand is capped.
|
|
22
23
|
- Each element of the `batch_results` array passed to `.call` must be a `Hash` with a `"request"` and/or `"response"` key (the request/response body to log for that item — either key alone is accepted). Any other shape is treated as malformed and skipped, with the skip counted in a `Rails.logger.warn` summary rather than raised.
|
|
23
24
|
|
|
24
25
|
## Minimal example
|
|
@@ -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"
|