data_redactor 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/{readme.md → README.md} +50 -2
- data/ext/data_redactor/matcher.c +2 -2
- data/ext/data_redactor/matcher.h +1 -1
- data/ext/data_redactor/patterns.c +6 -2
- data/lib/data_redactor/integrations/claude.rb +113 -0
- data/lib/data_redactor/integrations/llm_support.rb +63 -0
- data/lib/data_redactor/integrations/openai.rb +118 -0
- data/lib/data_redactor/integrations/rack.rb +2 -1
- data/lib/data_redactor/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bde76b717309c4a58b5fbe2cd85c7986622291a9e8f6a7c1e7423ae804ef0246
|
|
4
|
+
data.tar.gz: 497e5edb87745db889ab2900ef9a275335357311b747327d036d91a687008b94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4bc80d118f450b5798d8c7932fe39e1513c897842e40bf370f0d8b66d37ab180a3c4aae52ee1c85628a81a57c5612d67b969cd060b0023ab5d549be14c128bf7
|
|
7
|
+
data.tar.gz: af61f49db51ddc2d0806cb392700c93c4fde3a869e9e089f615ae26acabb6b777e38792d0fb061c53e5a01bc69355d8b95a9c5f60946e3a393df3f2ef958f066
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.0] - 2026-06-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Claude / OpenAI LLM integrations** — two new soft-required adapters that
|
|
14
|
+
scrub PII and secrets from LLM payloads before they leave the process and
|
|
15
|
+
from responses before they're logged:
|
|
16
|
+
- `DataRedactor::Integrations::Claude` — `.redact_messages` (handles the
|
|
17
|
+
`messages` array plus a top-level `system:` prompt; String or
|
|
18
|
+
array-of-content-block content) and `.redact_response` (Messages API
|
|
19
|
+
`content` text blocks).
|
|
20
|
+
- `DataRedactor::Integrations::OpenAI` — `.redact_messages` (Chat
|
|
21
|
+
Completions `messages`, including a `system` message and array-of-parts
|
|
22
|
+
content) and `.redact_response` (`choices[].message.content`).
|
|
23
|
+
Both operate on plain Ruby Hashes/Arrays with String or Symbol keys (no
|
|
24
|
+
runtime dependency on the `anthropic`/`openai` gems), return a deep copy
|
|
25
|
+
(never mutate the caller's input), pass non-text content blocks through
|
|
26
|
+
untouched, and forward `only:`/`except:`/`placeholder:` to
|
|
27
|
+
`DataRedactor.redact`.
|
|
28
|
+
|
|
29
|
+
## [0.10.1] - 2026-06-10
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **musl/Alpine load failure** — the `hashicorp_vault_batch_token` pattern used a
|
|
33
|
+
`{138,300}` interval whose upper bound exceeds POSIX `RE_DUP_MAX` (255). glibc
|
|
34
|
+
accepts it, but musl's `regcomp` rejects it ("Invalid contents of {}"), so the
|
|
35
|
+
native musl gem raised at load (`require "data_redactor"`) on Alpine. Capped the
|
|
36
|
+
bound at 255; tokens are still neutralized (prefix + 251+ chars redacted).
|
|
37
|
+
|
|
10
38
|
## [0.10.0] - 2026-06-09
|
|
11
39
|
|
|
12
40
|
### Changed
|
|
@@ -204,7 +232,10 @@ features as 0.7.1 plus the pipeline fix.
|
|
|
204
232
|
- `DataRedactor.redact(text)` module function returning the input with every match replaced by `[REDACTED]`.
|
|
205
233
|
- RSpec suite with one example per pattern.
|
|
206
234
|
|
|
207
|
-
[Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.
|
|
235
|
+
[Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.11.0...HEAD
|
|
236
|
+
[0.11.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.10.1...v0.11.0
|
|
237
|
+
[0.10.1]: https://github.com/danielefrisanco/data_redactor/compare/v0.10.0...v0.10.1
|
|
238
|
+
[0.10.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.9.0...v0.10.0
|
|
208
239
|
[0.9.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.8.0...v0.9.0
|
|
209
240
|
[0.8.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.2...v0.8.0
|
|
210
241
|
[0.7.2]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.1...v0.7.2
|
data/{readme.md → README.md}
RENAMED
|
@@ -275,6 +275,34 @@ Pass an empty subset (e.g. `scrub: [:headers]`) to opt out of body wrapping. For
|
|
|
275
275
|
|
|
276
276
|
> **Body wrapping is buffering.** The middleware reads the entire response body into memory before scanning. For streaming endpoints (SSE, large file downloads, Rack::Hijack) use `scrub: [:headers]` and rely on the Logger formatter for application logs instead.
|
|
277
277
|
|
|
278
|
+
### Claude / OpenAI LLM payloads
|
|
279
|
+
|
|
280
|
+
Sanitize LLM message payloads before they leave the process, and scrub responses before they're logged or stored. Both adapters operate on plain Ruby Hashes/Arrays (String **or** Symbol keys), so they work with the `anthropic`/`openai` gems, a raw HTTP client, or parsed JSON — no runtime dependency on any SDK. They **return a deep copy and never mutate your input**, and forward `only:`/`except:`/`placeholder:` to `DataRedactor.redact`.
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
require "data_redactor/integrations/claude"
|
|
284
|
+
|
|
285
|
+
# Redact a messages array before sending to Claude
|
|
286
|
+
safe_messages = DataRedactor::Integrations::Claude.redact_messages(messages)
|
|
287
|
+
client.messages.create(model: "claude-opus-4-8", max_tokens: 1024, messages: safe_messages)
|
|
288
|
+
|
|
289
|
+
# Redact the response (assistant content blocks) before logging
|
|
290
|
+
safe_response = DataRedactor::Integrations::Claude.redact_response(response)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
require "data_redactor/integrations/openai"
|
|
295
|
+
|
|
296
|
+
# Redact a messages array before sending to OpenAI
|
|
297
|
+
safe_messages = DataRedactor::Integrations::OpenAI.redact_messages(messages)
|
|
298
|
+
client.chat(parameters: { model: "gpt-4o", messages: safe_messages })
|
|
299
|
+
|
|
300
|
+
# Redact the response (choices[].message.content) before logging
|
|
301
|
+
safe_response = DataRedactor::Integrations::OpenAI.redact_response(response)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`content` may be a plain String or an array of content blocks/parts (`{ type: "text", text: "..." }`) — only the `text` of `text` blocks is redacted; image and other block types pass through untouched. For Claude, a top-level `system:` String is also redacted; for OpenAI, a `{ role: "system" }` message in the array is redacted like any other. Pass a bare `messages` array or the whole request Hash (with a `messages` key) — either works.
|
|
305
|
+
|
|
278
306
|
## Detected patterns (88 total)
|
|
279
307
|
|
|
280
308
|
The table below is a representative sample. Use `DataRedactor.pattern_names` for the canonical, machine-readable list — it stays in sync with the C extension automatically.
|
|
@@ -510,6 +538,26 @@ All payload sizes pass a correctness check (redaction count matches pure-Ruby `g
|
|
|
510
538
|
The previous engine (per-pattern `regexec`) was **4.25× slower** than pure Ruby on the
|
|
511
539
|
1 MB payload — a ~9× swing. Old numbers are in git history (`CHANGELOG.md` [0.9.0]).
|
|
512
540
|
|
|
541
|
+
#### Linear scaling
|
|
542
|
+
|
|
543
|
+
Throughput stays flat as input grows — the single-pass engine is O(N), so a 10×
|
|
544
|
+
larger payload takes ~10× longer and MB/s holds steady. The old per-pattern
|
|
545
|
+
`regexec` engine was O(N²) and fell off a cliff on large inputs (a 10 MB log took
|
|
546
|
+
tens of seconds); v19 redacts the same 10 MB in ~1.4 s.
|
|
547
|
+
|
|
548
|
+
| Size | Time | MB/s |
|
|
549
|
+
|--------|----------|---------|
|
|
550
|
+
| 1 KB | 0.14 ms | 7.1 |
|
|
551
|
+
| 100 KB | 13.4 ms | 7.3 |
|
|
552
|
+
| 1 MB | 142 ms | 7.0 |
|
|
553
|
+
| 10 MB | 1.42 s | 7.0 |
|
|
554
|
+
| 50 MB | 7.14 s | 7.0 |
|
|
555
|
+
|
|
556
|
+
No published benchmarks exist for comparable Ruby PII-redaction gems, so the
|
|
557
|
+
numbers above are absolute (vs pure-Ruby `gsub`), not a head-to-head against
|
|
558
|
+
another gem. Run `benchmark/scaling.rb` on your own hardware — absolute MB/s is
|
|
559
|
+
machine-dependent, but the flat curve is not.
|
|
560
|
+
|
|
513
561
|
## How it works
|
|
514
562
|
|
|
515
563
|
1. At load time, `Init_data_redactor` compiles all 85 regex patterns once using `regcomp` (POSIX ERE) and stores them as static `regex_t` structs. Patterns marked as boundary-wrapped are expanded with `wrap_boundary()` before compilation.
|
|
@@ -523,7 +571,7 @@ All C-side buffers are heap-allocated with `malloc`/`strdup` and freed before th
|
|
|
523
571
|
|
|
524
572
|
## Thread safety
|
|
525
573
|
|
|
526
|
-
`DataRedactor.redact` and `DataRedactor.scan` are safe to call concurrently from multiple threads.
|
|
574
|
+
`DataRedactor.redact` and `DataRedactor.scan` are safe to call concurrently from multiple threads. The v19 engine holds MRI's GVL for the duration of each call (no `rb_thread_call_without_gvl`), so concurrent calls are serialised by the GVL. Each call allocates its own working buffers; built-in engine state is read-only after `mm_init()` at load time.
|
|
527
575
|
|
|
528
576
|
`DataRedactor.add_pattern`, `remove_pattern`, and `clear_custom_patterns!` mutate a shared dynamic array and are **not** thread-safe. Register custom patterns once at boot — before spawning worker threads or forking — and they will be visible (read-only) to every subsequent `redact`/`scan` call.
|
|
529
577
|
|
|
@@ -540,4 +588,4 @@ Released under the [MIT License](LICENSE).
|
|
|
540
588
|
- **Pattern ordering matters** — patterns run sequentially. An early broad pattern (e.g. the 9-digit passport) may consume digits that a later pattern (e.g. credit card) depends on. Boundary wrapping mitigates this for pure-digit patterns.
|
|
541
589
|
- **AWS Secret Key (pattern 1)** — 40 consecutive base64 characters is a broad match. It can produce false positives in base64-encoded content such as embedded images or binary blobs.
|
|
542
590
|
- **Duplicate digit patterns** — several national ID formats share the same digit-length (11 digits: PESEL, Norwegian Fødselsnummer, Belgian National Number). They are kept as separate slots for clarity but the practical effect is that any 11-digit boundary-delimited number will be redacted.
|
|
543
|
-
- **
|
|
591
|
+
- **Single-pass overlap semantics** — built-in patterns are resolved by an index-order greedy claim: the lower-index pattern wins any region it matches. When two secrets abut with no separator, a rewrite-created word boundary can cause the second to be missed. This is rare in real text (secrets are almost always separator-delimited) and will be fixed by the upcoming longest-match-wins resolver in 1.0.
|
data/ext/data_redactor/matcher.c
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* matcher.c — the v19 multi-pattern engine, ported into the gem.
|
|
2
2
|
*
|
|
3
|
-
* Ported from prototypes/
|
|
3
|
+
* Ported from prototypes/multi_pattern_matcher/matcher19.c (the standalone prototype
|
|
4
4
|
* proven in docs/research_log.md). The matching core — regex parser -> Thompson
|
|
5
5
|
* bytecode -> per-pattern lazy DFA, the v14 first-byte filter, the v12 literal
|
|
6
6
|
* skip, the v18.1 anchor lowering, the v19 pure-digit and IBAN selective merges,
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* TODO.md §1d Gap 5 and the AKIA specs in spec/data_redactor_spec.rb.
|
|
23
23
|
*
|
|
24
24
|
* The infix-literal classification and the BM_INFIX hint table below are ported
|
|
25
|
-
* from prototypes/
|
|
25
|
+
* from prototypes/multi_pattern_matcher/gen_patterns.rb (which derived them from the
|
|
26
26
|
* same gem arrays at codegen time). They are pure optimisation hints — the
|
|
27
27
|
* first-byte filter computed from the program itself is what guarantees
|
|
28
28
|
* correctness — so a stale hint can only cost speed, never miss a match.
|
data/ext/data_redactor/matcher.h
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* interned DFA) with two selective merges (pure-digit run pass, IBAN union
|
|
9
9
|
* pass) and the v19.1 EOL-at-buffer-end fix. Zero dependencies beyond libc.
|
|
10
10
|
* See docs/research_log.md (v15..v19) for the derivation, and
|
|
11
|
-
* prototypes/
|
|
11
|
+
* prototypes/multi_pattern_matcher/ for the standalone prototype this is ported from.
|
|
12
12
|
*
|
|
13
13
|
* Built-in pattern engines are sourced from the gem's pattern arrays
|
|
14
14
|
* (pattern_strings[]/boundary_wrapped[]/pattern_required_literal[]), NOT a
|
|
@@ -458,8 +458,12 @@ const char *pattern_strings[NUM_PATTERNS] = {
|
|
|
458
458
|
"-----BEGIN PGP PRIVATE KEY BLOCK-----",
|
|
459
459
|
/* 29: HashiCorp Vault Service Token (hvs. + 90-120 base64url chars) */
|
|
460
460
|
"hvs\\.[A-Za-z0-9_-]{90,120}",
|
|
461
|
-
/* 30: HashiCorp Vault Batch Token (hvb. + 138
|
|
462
|
-
|
|
461
|
+
/* 30: HashiCorp Vault Batch Token (hvb. + 138+ base64url chars).
|
|
462
|
+
* Upper bound capped at POSIX RE_DUP_MAX (255), not gitleaks' 300: musl's
|
|
463
|
+
* regcomp rejects {m,n} with n>255 ("Invalid contents of {}"), so the gem
|
|
464
|
+
* failed to load on Alpine. 255 still neutralizes the token (prefix + 251+
|
|
465
|
+
* chars redacted); only an unusually long >255-char token leaves a dead tail. */
|
|
466
|
+
"hvb\\.[A-Za-z0-9_-]{138,255}",
|
|
463
467
|
/* 31: HashiCorp Terraform Cloud API Token (14 alphanum + .atlasv1. + 60-70 base64url chars) */
|
|
464
468
|
"[A-Za-z0-9]{14}\\.atlasv1\\.[A-Za-z0-9_=-]{60,70}",
|
|
465
469
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "data_redactor"
|
|
2
|
+
require "data_redactor/integrations/llm_support"
|
|
3
|
+
|
|
4
|
+
module DataRedactor
|
|
5
|
+
module Integrations
|
|
6
|
+
# Adapter for Anthropic Claude (Messages API) payloads. Scrubs PII and
|
|
7
|
+
# secrets from a request's `messages` (and top-level `system:` prompt)
|
|
8
|
+
# before they leave the process, and from a response's `content` blocks
|
|
9
|
+
# before they're logged or stored.
|
|
10
|
+
#
|
|
11
|
+
# Operates on plain Ruby Hashes/Arrays with either String or Symbol keys,
|
|
12
|
+
# so it works with the `anthropic` gem, a raw HTTP client, or parsed JSON —
|
|
13
|
+
# no runtime dependency on any SDK. Inputs are never mutated; a deep copy
|
|
14
|
+
# is returned.
|
|
15
|
+
#
|
|
16
|
+
# @example Scrub a request before sending
|
|
17
|
+
# require "data_redactor/integrations/claude"
|
|
18
|
+
#
|
|
19
|
+
# messages = [{ role: "user", content: "my email is alice@example.com" }]
|
|
20
|
+
# safe = DataRedactor::Integrations::Claude.redact_messages(messages)
|
|
21
|
+
# client.messages.create(model: "claude-opus-4-8", max_tokens: 1024,
|
|
22
|
+
# messages: safe)
|
|
23
|
+
#
|
|
24
|
+
# @example Scrub a response before logging
|
|
25
|
+
# resp = client.messages.create(...)
|
|
26
|
+
# logger.info DataRedactor::Integrations::Claude.redact_response(resp)
|
|
27
|
+
module Claude
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Redact a Claude `messages` array (and an optional top-level `system:`
|
|
31
|
+
# String) before sending the request. Returns a deep copy; the input is
|
|
32
|
+
# not mutated.
|
|
33
|
+
#
|
|
34
|
+
# Each message's `content` may be a String or an array of content blocks
|
|
35
|
+
# (`{ type: "text", text: "..." }`); only the `text` field of `text`
|
|
36
|
+
# blocks is redacted. Non-text blocks (e.g. `image`) pass through
|
|
37
|
+
# untouched.
|
|
38
|
+
#
|
|
39
|
+
# @param messages [Array<Hash>, Hash] either a bare array of
|
|
40
|
+
# `{ role:, content: }` hashes, or a request Hash containing a
|
|
41
|
+
# `messages` key and an optional `system` key. Keys may be String or
|
|
42
|
+
# Symbol.
|
|
43
|
+
# @param only forwarded to {DataRedactor.redact}
|
|
44
|
+
# @param except forwarded to {DataRedactor.redact}
|
|
45
|
+
# @param placeholder forwarded to {DataRedactor.redact}
|
|
46
|
+
# @return [Array<Hash>, Hash] a deep copy of the input with text leaves
|
|
47
|
+
# redacted; an Array if an Array was given, a Hash if a Hash was given.
|
|
48
|
+
# @example
|
|
49
|
+
# Claude.redact_messages([{ role: "user", content: "ssn 123-45-6789" }])
|
|
50
|
+
# #=> [{ role: "user", content: "ssn [REDACTED]" }]
|
|
51
|
+
def redact_messages(messages, only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
|
|
52
|
+
redact = ->(s) { DataRedactor.redact(s, only: only, except: except, placeholder: placeholder) }
|
|
53
|
+
|
|
54
|
+
if messages.is_a?(Hash)
|
|
55
|
+
out = LLMSupport.deep_copy(messages)
|
|
56
|
+
sys = LLMSupport.fetch(out, :system)
|
|
57
|
+
LLMSupport.put(out, :system, redact.call(sys)) if sys.is_a?(String)
|
|
58
|
+
list = LLMSupport.fetch(out, :messages)
|
|
59
|
+
LLMSupport.put(out, :messages, redact_message_list(list, redact)) if list.is_a?(Array)
|
|
60
|
+
out
|
|
61
|
+
else
|
|
62
|
+
redact_message_list(LLMSupport.deep_copy(messages), redact)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Redact a Claude Messages API response before logging or storing it.
|
|
67
|
+
# Returns a deep copy; the input is not mutated.
|
|
68
|
+
#
|
|
69
|
+
# Walks the response's `content` array and redacts the `text` field of
|
|
70
|
+
# each `text` block, leaving the rest of the response (id, role, usage,
|
|
71
|
+
# non-text blocks) intact.
|
|
72
|
+
#
|
|
73
|
+
# @param response [Hash] a Claude response Hash with a `content` array of
|
|
74
|
+
# blocks. Keys may be String or Symbol.
|
|
75
|
+
# @param only forwarded to {DataRedactor.redact}
|
|
76
|
+
# @param except forwarded to {DataRedactor.redact}
|
|
77
|
+
# @param placeholder forwarded to {DataRedactor.redact}
|
|
78
|
+
# @return [Hash] a deep copy of the response with text blocks redacted.
|
|
79
|
+
# @example
|
|
80
|
+
# Claude.redact_response(
|
|
81
|
+
# "content" => [{ "type" => "text", "text" => "card 4111111111111111" }]
|
|
82
|
+
# )
|
|
83
|
+
# #=> {"content"=>[{"type"=>"text", "text"=>"card [REDACTED]"}]}
|
|
84
|
+
def redact_response(response, only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
|
|
85
|
+
redact = ->(s) { DataRedactor.redact(s, only: only, except: except, placeholder: placeholder) }
|
|
86
|
+
out = LLMSupport.deep_copy(response)
|
|
87
|
+
return out unless out.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
content = LLMSupport.fetch(out, :content)
|
|
90
|
+
LLMSupport.put(out, :content, LLMSupport.redact_text_blocks(content, redact)) if content.is_a?(Array)
|
|
91
|
+
out
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @!visibility private
|
|
95
|
+
# Redact each message's content (String or array of blocks) in place.
|
|
96
|
+
# Expects an already-deep-copied list.
|
|
97
|
+
def redact_message_list(messages, redact)
|
|
98
|
+
messages.map do |msg|
|
|
99
|
+
next msg unless msg.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
content = LLMSupport.fetch(msg, :content)
|
|
102
|
+
case content
|
|
103
|
+
when String
|
|
104
|
+
LLMSupport.put(msg, :content, redact.call(content))
|
|
105
|
+
when Array
|
|
106
|
+
LLMSupport.put(msg, :content, LLMSupport.redact_text_blocks(content, redact))
|
|
107
|
+
end
|
|
108
|
+
msg
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "data_redactor"
|
|
2
|
+
|
|
3
|
+
module DataRedactor
|
|
4
|
+
module Integrations
|
|
5
|
+
# Shared helpers for the LLM payload adapters ({Claude}, {OpenAI}).
|
|
6
|
+
#
|
|
7
|
+
# Both adapters operate on plain Ruby Hashes/Arrays whose keys may be
|
|
8
|
+
# String or Symbol, never mutate the caller's input, and redact only the
|
|
9
|
+
# `text` field of text content blocks. This module holds that common,
|
|
10
|
+
# non-trivial logic; the per-provider modules keep only their own
|
|
11
|
+
# provider-specific shape walking.
|
|
12
|
+
#
|
|
13
|
+
# @!visibility private
|
|
14
|
+
module LLMSupport
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Read a key from a Hash that may use String or Symbol keys.
|
|
18
|
+
# @return the value under the Symbol or String form of `key`, or nil.
|
|
19
|
+
def fetch(hash, key)
|
|
20
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Write a value back under whichever key form (String/Symbol) the Hash
|
|
24
|
+
# already uses for `key`, defaulting to the Symbol form.
|
|
25
|
+
# @return [void]
|
|
26
|
+
def put(hash, key, value)
|
|
27
|
+
if hash.key?(key.to_s)
|
|
28
|
+
hash[key.to_s] = value
|
|
29
|
+
else
|
|
30
|
+
hash[key] = value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Recursively copy a Hash/Array/String structure so the original is never
|
|
35
|
+
# mutated. Non-container, non-String leaves are returned as-is.
|
|
36
|
+
# @return a deep copy of `obj`.
|
|
37
|
+
def deep_copy(obj)
|
|
38
|
+
case obj
|
|
39
|
+
when Hash then obj.each_with_object({}) { |(k, v), o| o[k] = deep_copy(v) }
|
|
40
|
+
when Array then obj.map { |v| deep_copy(v) }
|
|
41
|
+
when String then obj.dup
|
|
42
|
+
else obj
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Redact the `text` field of each `text` content block in `blocks`,
|
|
47
|
+
# passing non-text blocks (e.g. images) through untouched. Mutates the
|
|
48
|
+
# blocks in `blocks` (call on an already-copied structure).
|
|
49
|
+
# @param blocks [Array] content blocks.
|
|
50
|
+
# @param redact [#call] a String -> String redaction lambda.
|
|
51
|
+
# @return [Array] the same `blocks` array, with text blocks redacted.
|
|
52
|
+
def redact_text_blocks(blocks, redact)
|
|
53
|
+
blocks.map do |block|
|
|
54
|
+
next block unless block.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
text = fetch(block, :text)
|
|
57
|
+
put(block, :text, redact.call(text)) if text.is_a?(String)
|
|
58
|
+
block
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "data_redactor"
|
|
2
|
+
require "data_redactor/integrations/llm_support"
|
|
3
|
+
|
|
4
|
+
module DataRedactor
|
|
5
|
+
module Integrations
|
|
6
|
+
# Adapter for OpenAI Chat Completions payloads. Scrubs PII and secrets from
|
|
7
|
+
# a request's `messages` before they leave the process, and from a
|
|
8
|
+
# response's `choices[].message.content` before they're logged or stored.
|
|
9
|
+
#
|
|
10
|
+
# Operates on plain Ruby Hashes/Arrays with either String or Symbol keys,
|
|
11
|
+
# so it works with the `openai` gem, a raw HTTP client, or parsed JSON — no
|
|
12
|
+
# runtime dependency on any SDK. Inputs are never mutated; a deep copy is
|
|
13
|
+
# returned.
|
|
14
|
+
#
|
|
15
|
+
# @example Scrub a request before sending
|
|
16
|
+
# require "data_redactor/integrations/openai"
|
|
17
|
+
#
|
|
18
|
+
# messages = [{ role: "user", content: "my email is alice@example.com" }]
|
|
19
|
+
# safe = DataRedactor::Integrations::OpenAI.redact_messages(messages)
|
|
20
|
+
# client.chat(parameters: { model: "gpt-4o", messages: safe })
|
|
21
|
+
#
|
|
22
|
+
# @example Scrub a response before logging
|
|
23
|
+
# resp = client.chat(parameters: { ... })
|
|
24
|
+
# logger.info DataRedactor::Integrations::OpenAI.redact_response(resp)
|
|
25
|
+
module OpenAI
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Redact an OpenAI `messages` array before sending the request. Returns a
|
|
29
|
+
# deep copy; the input is not mutated.
|
|
30
|
+
#
|
|
31
|
+
# Each message's `content` may be a String or an array of parts
|
|
32
|
+
# (`{ type: "text", text: "..." }`); only the `text` field of `text`
|
|
33
|
+
# parts is redacted. Non-text parts (e.g. `image_url`) pass through
|
|
34
|
+
# untouched. A `{ role: "system", content: ... }` entry is redacted like
|
|
35
|
+
# any other message (OpenAI carries the system prompt in the array).
|
|
36
|
+
#
|
|
37
|
+
# @param messages [Array<Hash>, Hash] either a bare array of
|
|
38
|
+
# `{ role:, content: }` hashes, or a request Hash containing a
|
|
39
|
+
# `messages` key. Keys may be String or Symbol.
|
|
40
|
+
# @param only forwarded to {DataRedactor.redact}
|
|
41
|
+
# @param except forwarded to {DataRedactor.redact}
|
|
42
|
+
# @param placeholder forwarded to {DataRedactor.redact}
|
|
43
|
+
# @return [Array<Hash>, Hash] a deep copy of the input with text leaves
|
|
44
|
+
# redacted; an Array if an Array was given, a Hash if a Hash was given.
|
|
45
|
+
# @example
|
|
46
|
+
# OpenAI.redact_messages([{ role: "user", content: "ssn 123-45-6789" }])
|
|
47
|
+
# #=> [{ role: "user", content: "ssn [REDACTED]" }]
|
|
48
|
+
def redact_messages(messages, only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
|
|
49
|
+
redact = ->(s) { DataRedactor.redact(s, only: only, except: except, placeholder: placeholder) }
|
|
50
|
+
|
|
51
|
+
if messages.is_a?(Hash)
|
|
52
|
+
out = LLMSupport.deep_copy(messages)
|
|
53
|
+
list = LLMSupport.fetch(out, :messages)
|
|
54
|
+
LLMSupport.put(out, :messages, redact_message_list(list, redact)) if list.is_a?(Array)
|
|
55
|
+
out
|
|
56
|
+
else
|
|
57
|
+
redact_message_list(LLMSupport.deep_copy(messages), redact)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Redact an OpenAI Chat Completions response before logging or storing it.
|
|
62
|
+
# Returns a deep copy; the input is not mutated.
|
|
63
|
+
#
|
|
64
|
+
# Walks `choices[].message.content` and redacts each (String content),
|
|
65
|
+
# leaving the rest of the response (id, usage, finish_reason) intact.
|
|
66
|
+
#
|
|
67
|
+
# @param response [Hash] a response Hash with a `choices` array, each
|
|
68
|
+
# choice carrying a `message` Hash with a `content` String. Keys may be
|
|
69
|
+
# String or Symbol.
|
|
70
|
+
# @param only forwarded to {DataRedactor.redact}
|
|
71
|
+
# @param except forwarded to {DataRedactor.redact}
|
|
72
|
+
# @param placeholder forwarded to {DataRedactor.redact}
|
|
73
|
+
# @return [Hash] a deep copy of the response with message content redacted.
|
|
74
|
+
# @example
|
|
75
|
+
# OpenAI.redact_response(
|
|
76
|
+
# "choices" => [{ "message" => { "content" => "card 4111111111111111" } }]
|
|
77
|
+
# )
|
|
78
|
+
# #=> {"choices"=>[{"message"=>{"content"=>"card [REDACTED]"}}]}
|
|
79
|
+
def redact_response(response, only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
|
|
80
|
+
redact = ->(s) { DataRedactor.redact(s, only: only, except: except, placeholder: placeholder) }
|
|
81
|
+
out = LLMSupport.deep_copy(response)
|
|
82
|
+
return out unless out.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
choices = LLMSupport.fetch(out, :choices)
|
|
85
|
+
return out unless choices.is_a?(Array)
|
|
86
|
+
|
|
87
|
+
choices.each do |choice|
|
|
88
|
+
next unless choice.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
message = LLMSupport.fetch(choice, :message)
|
|
91
|
+
next unless message.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
content = LLMSupport.fetch(message, :content)
|
|
94
|
+
LLMSupport.put(message, :content, redact.call(content)) if content.is_a?(String)
|
|
95
|
+
end
|
|
96
|
+
out
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @!visibility private
|
|
100
|
+
# Redact each message's content (String or array of parts) in place.
|
|
101
|
+
# Expects an already-deep-copied list.
|
|
102
|
+
def redact_message_list(messages, redact)
|
|
103
|
+
messages.map do |msg|
|
|
104
|
+
next msg unless msg.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
content = LLMSupport.fetch(msg, :content)
|
|
107
|
+
case content
|
|
108
|
+
when String
|
|
109
|
+
LLMSupport.put(msg, :content, redact.call(content))
|
|
110
|
+
when Array
|
|
111
|
+
LLMSupport.put(msg, :content, LLMSupport.redact_text_blocks(content, redact))
|
|
112
|
+
end
|
|
113
|
+
msg
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -2,7 +2,8 @@ require "data_redactor"
|
|
|
2
2
|
|
|
3
3
|
module DataRedactor
|
|
4
4
|
# Namespace for the optional framework adapters under
|
|
5
|
-
# +lib/data_redactor/integrations/+ ({Logger}, +Rails+, {Rack}
|
|
5
|
+
# +lib/data_redactor/integrations/+ ({Logger}, +Rails+, {Rack},
|
|
6
|
+
# {Claude}, {OpenAI}).
|
|
6
7
|
#
|
|
7
8
|
# Each adapter is soft-required — none load with +require "data_redactor"+;
|
|
8
9
|
# +require+ only the one you need. They add no runtime gem dependencies and
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: data_redactor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniele Frisanco
|
|
@@ -121,6 +121,7 @@ extra_rdoc_files: []
|
|
|
121
121
|
files:
|
|
122
122
|
- CHANGELOG.md
|
|
123
123
|
- LICENSE
|
|
124
|
+
- README.md
|
|
124
125
|
- ext/data_redactor/custom_patterns.c
|
|
125
126
|
- ext/data_redactor/custom_patterns.h
|
|
126
127
|
- ext/data_redactor/data_redactor.c
|
|
@@ -137,12 +138,14 @@ files:
|
|
|
137
138
|
- ext/data_redactor/scan.h
|
|
138
139
|
- ext/data_redactor/tags.h
|
|
139
140
|
- lib/data_redactor.rb
|
|
141
|
+
- lib/data_redactor/integrations/claude.rb
|
|
142
|
+
- lib/data_redactor/integrations/llm_support.rb
|
|
140
143
|
- lib/data_redactor/integrations/logger.rb
|
|
144
|
+
- lib/data_redactor/integrations/openai.rb
|
|
141
145
|
- lib/data_redactor/integrations/rack.rb
|
|
142
146
|
- lib/data_redactor/integrations/rails.rb
|
|
143
147
|
- lib/data_redactor/name_pattern.rb
|
|
144
148
|
- lib/data_redactor/version.rb
|
|
145
|
-
- readme.md
|
|
146
149
|
homepage: https://github.com/danielefrisanco/data_redactor
|
|
147
150
|
licenses:
|
|
148
151
|
- MIT
|