scrubber_rb 0.1.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 +7 -0
- data/.cargo/config.toml +21 -0
- data/CHANGELOG.md +59 -0
- data/Cargo.lock +626 -0
- data/Cargo.toml +10 -0
- data/LICENSE +21 -0
- data/README.md +298 -0
- data/SECURITY.md +54 -0
- data/ext/scrubber_rb/Cargo.toml +41 -0
- data/ext/scrubber_rb/build.rs +6 -0
- data/ext/scrubber_rb/extconf.rb +6 -0
- data/ext/scrubber_rb/src/detectors/checksum.rs +222 -0
- data/ext/scrubber_rb/src/detectors/mod.rs +636 -0
- data/ext/scrubber_rb/src/detectors/upi.rs +138 -0
- data/ext/scrubber_rb/src/detectors/validate.rs +339 -0
- data/ext/scrubber_rb/src/engine.rs +716 -0
- data/ext/scrubber_rb/src/lib.rs +247 -0
- data/ext/scrubber_rb/src/nogvl.rs +84 -0
- data/ext/scrubber_rb/src/offsets.rs +118 -0
- data/ext/scrubber_rb/src/pattern.rs +319 -0
- data/ext/scrubber_rb/src/replace.rs +238 -0
- data/lib/scrubber/instance.rb +223 -0
- data/lib/scrubber/llm_guard.rb +79 -0
- data/lib/scrubber/log_formatter.rb +40 -0
- data/lib/scrubber/match.rb +27 -0
- data/lib/scrubber/middleware.rb +124 -0
- data/lib/scrubber/version.rb +5 -0
- data/lib/scrubber_rb.rb +109 -0
- data/sig/scrubber_rb.rbs +150 -0
- metadata +96 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scrubber
|
|
4
|
+
# A compiled, immutable scrubber.
|
|
5
|
+
#
|
|
6
|
+
# Compiling ~35 regexes into one automaton is the expensive part; scanning is
|
|
7
|
+
# cheap. Build one of these at boot and reuse it forever:
|
|
8
|
+
#
|
|
9
|
+
# SCRUBBER = Scrubber.new(replacement: :hash)
|
|
10
|
+
# SCRUBBER.scrub(line)
|
|
11
|
+
#
|
|
12
|
+
# Instances are frozen and hold no per-call state, so a single instance is
|
|
13
|
+
# safe to share across every thread in the process.
|
|
14
|
+
class Instance
|
|
15
|
+
REPLACEMENTS = %i[label mask hash remove].freeze
|
|
16
|
+
|
|
17
|
+
# Read in 1MB slices, and never cut closer than this to the end of the
|
|
18
|
+
# buffer, so a match straddling a chunk boundary still sees both halves.
|
|
19
|
+
CHUNK_SIZE = 1024 * 1024
|
|
20
|
+
CARRY_SIZE = 8 * 1024
|
|
21
|
+
|
|
22
|
+
attr_reader :detectors, :custom, :replacement, :hash_salt
|
|
23
|
+
|
|
24
|
+
def initialize(detectors: Scrubber::DEFAULTS, custom: {}, replacement: :label, hash_salt: nil)
|
|
25
|
+
@detectors = normalize_detectors(detectors)
|
|
26
|
+
@custom = normalize_custom(custom)
|
|
27
|
+
@replacement = normalize_replacement(replacement)
|
|
28
|
+
@hash_salt = hash_salt&.to_s
|
|
29
|
+
|
|
30
|
+
@native = Native.new(
|
|
31
|
+
@detectors.map(&:to_s),
|
|
32
|
+
@custom.map { |name, regexp| [name.to_s, regexp.source, regexp.options] },
|
|
33
|
+
@replacement.to_s,
|
|
34
|
+
@hash_salt
|
|
35
|
+
)
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Redact every match in `text`, returning a new String with the same
|
|
40
|
+
# encoding. The input is never mutated, and frozen input is fine.
|
|
41
|
+
def scrub(text)
|
|
42
|
+
str = coerce(text)
|
|
43
|
+
replaced = @native.scrub(str)
|
|
44
|
+
return str.dup if replaced.nil?
|
|
45
|
+
|
|
46
|
+
replaced.force_encoding(str.encoding)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Redact in place. Returns the same object, so it raises FrozenError on a
|
|
50
|
+
# frozen string exactly like every other Ruby bang method.
|
|
51
|
+
def scrub!(text)
|
|
52
|
+
str = coerce(text)
|
|
53
|
+
replaced = @native.scrub(str)
|
|
54
|
+
return str if replaced.nil?
|
|
55
|
+
|
|
56
|
+
str.replace(replaced.force_encoding(str.encoding))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Find matches without replacing them.
|
|
60
|
+
#
|
|
61
|
+
# Scrubber.detect("mail nik@example.com")
|
|
62
|
+
# # => [#<Scrubber::Match type=:email 5...20 preview="n**@e******.com">]
|
|
63
|
+
#
|
|
64
|
+
# Offsets are character offsets into `text`, not byte offsets, so they index
|
|
65
|
+
# the Ruby string correctly even when it contains emoji or Devanagari.
|
|
66
|
+
def detect(text)
|
|
67
|
+
str = coerce(text)
|
|
68
|
+
# Only UTF-8 needs byte->character conversion; in single-byte encodings
|
|
69
|
+
# the two are the same number.
|
|
70
|
+
char_offsets = str.encoding == Encoding::UTF_8
|
|
71
|
+
@native.detect(str, char_offsets).map do |type, from, to, preview|
|
|
72
|
+
Match.new(type: type.to_sym, begin: from, end: to, preview: preview)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# True if anything at all would be redacted. Cheaper than `scrub` when you
|
|
77
|
+
# only need a yes/no (an audit check, a test assertion, a CI gate).
|
|
78
|
+
def match?(text)
|
|
79
|
+
# `detect` stops at the match list; it never builds the output string.
|
|
80
|
+
!@native.detect(coerce(text), false).empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Stream a file through the engine.
|
|
84
|
+
#
|
|
85
|
+
# Reads in 1MB chunks and cuts each chunk at the last line break before an
|
|
86
|
+
# 8KB carry window, so a match spanning a chunk boundary is never sliced in
|
|
87
|
+
# half. Multi-line PEM blocks get an extra guard: if a chunk would end
|
|
88
|
+
# between BEGIN and END, the cut moves back before the BEGIN.
|
|
89
|
+
#
|
|
90
|
+
# Returns the number of bytes written.
|
|
91
|
+
def scrub_file(input_path, output_path, chunk_size: CHUNK_SIZE)
|
|
92
|
+
written = 0
|
|
93
|
+
File.open(input_path, "rb") do |input|
|
|
94
|
+
File.open(output_path, "wb") do |output|
|
|
95
|
+
each_safe_chunk(input, chunk_size) do |chunk|
|
|
96
|
+
written += output.write(@native.scrub(chunk) || chunk)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
written
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# How many compiled patterns back this instance. Detectors expand to more
|
|
104
|
+
# than one rule each in a few cases (`:api_key` alone is ~19).
|
|
105
|
+
def rule_count
|
|
106
|
+
@native.rule_count
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def inspect
|
|
110
|
+
"#<Scrubber::Instance detectors=#{@detectors.size} rules=#{rule_count} " \
|
|
111
|
+
"replacement=#{@replacement.inspect}>"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Config identity, used to memoize module-level `Scrubber.scrub` calls.
|
|
115
|
+
def cache_key
|
|
116
|
+
self.class.cache_key(
|
|
117
|
+
detectors: @detectors, custom: @custom,
|
|
118
|
+
replacement: @replacement, hash_salt: @hash_salt
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.cache_key(detectors:, custom:, replacement:, hash_salt:)
|
|
123
|
+
customs = custom.to_a.map do |name, regexp|
|
|
124
|
+
source = regexp.respond_to?(:source) ? regexp.source : regexp.to_s
|
|
125
|
+
options = regexp.respond_to?(:options) ? regexp.options : 0
|
|
126
|
+
[name.to_sym, source, options]
|
|
127
|
+
end
|
|
128
|
+
[Array(detectors).map(&:to_sym).sort, customs.sort, replacement.to_sym, hash_salt&.to_s]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def coerce(text)
|
|
134
|
+
return text if text.is_a?(String)
|
|
135
|
+
raise TypeError, "expected a String, got #{text.class}" unless text.respond_to?(:to_str)
|
|
136
|
+
|
|
137
|
+
text.to_str
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_detectors(detectors)
|
|
141
|
+
list = Array(detectors).map do |d|
|
|
142
|
+
unless d.respond_to?(:to_sym)
|
|
143
|
+
raise ConfigurationError,
|
|
144
|
+
"detector names must be Symbols or Strings, got #{d.class}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
d.to_sym
|
|
148
|
+
end
|
|
149
|
+
list.uniq.freeze
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def normalize_custom(custom)
|
|
153
|
+
raise ConfigurationError, "custom: must be a Hash of name => Regexp" unless custom.respond_to?(:to_h)
|
|
154
|
+
|
|
155
|
+
custom.to_h.each_with_object({}) do |(name, regexp), out|
|
|
156
|
+
unless regexp.is_a?(Regexp)
|
|
157
|
+
raise ConfigurationError,
|
|
158
|
+
"custom detector #{name.inspect} must be a Regexp, got #{regexp.class}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
out[name.to_sym] = regexp
|
|
162
|
+
end.freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def normalize_replacement(replacement)
|
|
166
|
+
sym = replacement.to_sym
|
|
167
|
+
unless REPLACEMENTS.include?(sym)
|
|
168
|
+
raise ConfigurationError,
|
|
169
|
+
"unknown replacement #{replacement.inspect}. Expected one of: #{REPLACEMENTS.join(", ")}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
sym
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Yields byte slices that are safe to scan independently.
|
|
176
|
+
def each_safe_chunk(input, chunk_size)
|
|
177
|
+
buffer = +""
|
|
178
|
+
buffer.force_encoding(Encoding::BINARY)
|
|
179
|
+
|
|
180
|
+
while (chunk = input.read(chunk_size))
|
|
181
|
+
buffer << chunk
|
|
182
|
+
next if buffer.bytesize <= CARRY_SIZE
|
|
183
|
+
|
|
184
|
+
cut = safe_cut(buffer)
|
|
185
|
+
next if cut.zero?
|
|
186
|
+
|
|
187
|
+
yield buffer.byteslice(0, cut)
|
|
188
|
+
buffer = buffer.byteslice(cut, buffer.bytesize - cut)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
yield buffer unless buffer.empty?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Where can this buffer be cut without slicing a match in half?
|
|
195
|
+
def safe_cut(buffer)
|
|
196
|
+
limit = buffer.bytesize - CARRY_SIZE
|
|
197
|
+
return 0 if limit <= 0
|
|
198
|
+
|
|
199
|
+
cut = buffer.rindex("\n", limit - 1)
|
|
200
|
+
cut = cut ? cut + 1 : limit
|
|
201
|
+
pem_safe_cut(buffer, cut)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A PEM block can be longer than the carry window, so cutting at a line
|
|
205
|
+
# break is not enough: if the head we are about to emit opens a block it
|
|
206
|
+
# does not close, back the cut up to just before that BEGIN.
|
|
207
|
+
def pem_safe_cut(buffer, cut)
|
|
208
|
+
# Give up on the guard rather than buffer a whole file: a PEM block this
|
|
209
|
+
# big is malformed, and unbounded memory growth is the worse failure.
|
|
210
|
+
return cut if buffer.bytesize > CHUNK_SIZE * 8
|
|
211
|
+
|
|
212
|
+
head = buffer.byteslice(0, cut)
|
|
213
|
+
opens = head.scan("-----BEGIN").size
|
|
214
|
+
closes = head.scan("-----END").size
|
|
215
|
+
return cut if opens <= closes
|
|
216
|
+
|
|
217
|
+
last_begin = head.rindex("-----BEGIN")
|
|
218
|
+
return cut if last_begin.nil? || last_begin.zero?
|
|
219
|
+
|
|
220
|
+
last_begin
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scrubber
|
|
4
|
+
# Redact prompts on their way to a third-party model API.
|
|
5
|
+
#
|
|
6
|
+
# guard = Scrubber::LLMGuard.new(replacement: :hash)
|
|
7
|
+
# chat.ask(guard.call(user_message))
|
|
8
|
+
#
|
|
9
|
+
# `:hash` is the interesting default here. With `[EMAIL:9f86d081]` tokens the
|
|
10
|
+
# model can still reason about "the same customer" across a conversation, and
|
|
11
|
+
# your logs of that conversation stay correlatable, without the value ever
|
|
12
|
+
# crossing the network.
|
|
13
|
+
#
|
|
14
|
+
# It accepts the shapes prompts actually come in:
|
|
15
|
+
#
|
|
16
|
+
# guard.call("my email is nik@example.com")
|
|
17
|
+
# guard.call([{ role: "user", content: "..." }])
|
|
18
|
+
# guard.call({ role: "user", content: "..." })
|
|
19
|
+
#
|
|
20
|
+
# There is no framework hook here on purpose. `LLMGuard` is a plain callable,
|
|
21
|
+
# so it drops into RubyLLM, langchainrb, ruby-openai, anthropic-sdk-ruby, or a
|
|
22
|
+
# hand-rolled `Net::HTTP` call the same way. See the README for the RubyLLM
|
|
23
|
+
# wrapper pattern.
|
|
24
|
+
class LLMGuard
|
|
25
|
+
# Message hash keys whose values are prompt text.
|
|
26
|
+
CONTENT_KEYS = %w[content text prompt input].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :scrubber
|
|
29
|
+
|
|
30
|
+
def initialize(scrubber: nil, replacement: :hash, **options)
|
|
31
|
+
@scrubber = scrubber || Scrubber.new(replacement: replacement, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Redact `input`, preserving its shape.
|
|
35
|
+
def call(input)
|
|
36
|
+
case input
|
|
37
|
+
when String then @scrubber.scrub(input)
|
|
38
|
+
when Array then input.map { |item| call(item) }
|
|
39
|
+
when Hash then scrub_hash(input)
|
|
40
|
+
when Symbol, Numeric, NilClass, TrueClass, FalseClass then input
|
|
41
|
+
else
|
|
42
|
+
input.respond_to?(:to_str) ? @scrubber.scrub(input.to_str) : input
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
alias scrub call
|
|
46
|
+
|
|
47
|
+
# Usable directly as a block: `messages.map(&guard)`.
|
|
48
|
+
def to_proc
|
|
49
|
+
method(:call).to_proc
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# What would have been redacted. Handy for a "we blocked N leaks today"
|
|
53
|
+
# metric, or for failing a test when a prompt builder regresses.
|
|
54
|
+
def findings(input)
|
|
55
|
+
case input
|
|
56
|
+
when String then @scrubber.detect(input)
|
|
57
|
+
when Array then input.flat_map { |item| findings(item) }
|
|
58
|
+
when Hash then content_values(input).flat_map { |v| findings(v) }
|
|
59
|
+
else []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def scrub_hash(hash)
|
|
66
|
+
hash.each_with_object(hash.class.new) do |(key, value), out|
|
|
67
|
+
out[key] = content_key?(key) || value.is_a?(Array) || value.is_a?(Hash) ? call(value) : value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def content_values(hash)
|
|
72
|
+
hash.filter_map { |key, value| value if content_key?(key) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def content_key?(key)
|
|
76
|
+
CONTENT_KEYS.include?(key.to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scrubber
|
|
4
|
+
# Wraps an existing Logger formatter and redacts whatever it produces.
|
|
5
|
+
#
|
|
6
|
+
# # config/initializers/scrubber.rb
|
|
7
|
+
# Rails.logger.formatter = Scrubber::LogFormatter.new(Rails.logger.formatter)
|
|
8
|
+
#
|
|
9
|
+
# Wrapping the formatter rather than the logger means it catches everything:
|
|
10
|
+
# your own `Rails.logger.info`, Active Record's SQL echo, Rack's request
|
|
11
|
+
# lines, and the exception messages your error middleware logs on the way out.
|
|
12
|
+
#
|
|
13
|
+
# It runs on the log write path, so it is worth knowing what it costs: one
|
|
14
|
+
# Rust scan per line, which on typical log lines is a few microseconds. If
|
|
15
|
+
# your app is log-bound, narrow `detectors:` to what you actually care about.
|
|
16
|
+
class LogFormatter
|
|
17
|
+
attr_reader :scrubber
|
|
18
|
+
|
|
19
|
+
# @param formatter [#call] the formatter to wrap. Defaults to
|
|
20
|
+
# `Logger::Formatter`, which is what a bare `Logger` uses.
|
|
21
|
+
# @param scrubber [Scrubber::Instance] a prebuilt engine, if you have one.
|
|
22
|
+
# @param options [Hash] otherwise, options for {Scrubber.new}.
|
|
23
|
+
def initialize(formatter = nil, scrubber: nil, **options)
|
|
24
|
+
@formatter = formatter || self.class.default_formatter
|
|
25
|
+
@scrubber = scrubber || Scrubber.new(**options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(severity, time, progname, msg)
|
|
29
|
+
formatted = @formatter.call(severity, time, progname, msg)
|
|
30
|
+
return formatted unless formatted.is_a?(String)
|
|
31
|
+
|
|
32
|
+
@scrubber.scrub(formatted)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.default_formatter
|
|
36
|
+
require "logger"
|
|
37
|
+
::Logger::Formatter.new
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scrubber
|
|
4
|
+
# One thing the engine found, without the thing itself.
|
|
5
|
+
#
|
|
6
|
+
# `begin` and `end` are character offsets into the string you passed, so
|
|
7
|
+
# `text[match.begin...match.end]` gives you the raw value back if you really
|
|
8
|
+
# want it. `preview` is already masked — it exists so you can log "we found an
|
|
9
|
+
# email here" without logging the email.
|
|
10
|
+
Match = Struct.new(:type, :begin, :end, :preview, keyword_init: true) do
|
|
11
|
+
# The span as a Range, ready for `String#[]`.
|
|
12
|
+
def to_range
|
|
13
|
+
self.begin...self.end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Length of the match in characters.
|
|
17
|
+
def length
|
|
18
|
+
self.end - self.begin
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def inspect
|
|
22
|
+
"#<Scrubber::Match type=#{type.inspect} #{self.begin}...#{self.end} " \
|
|
23
|
+
"preview=#{preview.inspect}>"
|
|
24
|
+
end
|
|
25
|
+
alias_method :to_s, :inspect
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scrubber
|
|
4
|
+
# Rack middleware that makes a redacted view of the request available to
|
|
5
|
+
# everything downstream, and redacts exception messages on the way out.
|
|
6
|
+
#
|
|
7
|
+
# use Scrubber::Middleware, detectors: Scrubber::DEFAULTS
|
|
8
|
+
#
|
|
9
|
+
# == What it does not do
|
|
10
|
+
#
|
|
11
|
+
# It does not rewrite `QUERY_STRING`, `rack.input`, or `params`. Redacting the
|
|
12
|
+
# request the application is about to act on would break every login form in
|
|
13
|
+
# the world: the app needs the real password to check it. What leaks is not
|
|
14
|
+
# the request, it is the *record* of the request — the log line, the error
|
|
15
|
+
# tracker payload, the APM trace.
|
|
16
|
+
#
|
|
17
|
+
# So this middleware gives you redacted copies to record:
|
|
18
|
+
#
|
|
19
|
+
# env["scrubber.query_string"] # "user=x&password=[PASSWORD_PAIR]"
|
|
20
|
+
# env["scrubber.params"] # { "user" => "x", "password" => "[PASSWORD_PAIR]" }
|
|
21
|
+
# env["scrubber.instance"] # the engine, for anything else you log
|
|
22
|
+
#
|
|
23
|
+
# and it redacts the message of any exception raised further down the stack
|
|
24
|
+
# before re-raising it, so an error tracker that never heard of this gem still
|
|
25
|
+
# gets a clean payload.
|
|
26
|
+
class Middleware
|
|
27
|
+
QUERY_STRING = "QUERY_STRING"
|
|
28
|
+
ENV_QUERY = "scrubber.query_string"
|
|
29
|
+
ENV_PARAMS = "scrubber.params"
|
|
30
|
+
ENV_INSTANCE = "scrubber.instance"
|
|
31
|
+
|
|
32
|
+
attr_reader :scrubber
|
|
33
|
+
|
|
34
|
+
# @param app [#call] the next Rack app.
|
|
35
|
+
# @param scrubber [Scrubber::Instance] a prebuilt engine, if you have one.
|
|
36
|
+
# @param scrub_exceptions [Boolean] redact exception messages (default true).
|
|
37
|
+
# @param options [Hash] otherwise, options for {Scrubber.new}.
|
|
38
|
+
def initialize(app, scrubber: nil, scrub_exceptions: true, **options)
|
|
39
|
+
@app = app
|
|
40
|
+
@scrubber = scrubber || Scrubber.new(**options)
|
|
41
|
+
@scrub_exceptions = scrub_exceptions
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(env)
|
|
45
|
+
annotate(env)
|
|
46
|
+
@app.call(env)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
raise unless @scrub_exceptions
|
|
49
|
+
|
|
50
|
+
raise redacted(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def annotate(env)
|
|
56
|
+
env[ENV_INSTANCE] = @scrubber
|
|
57
|
+
query = env[QUERY_STRING]
|
|
58
|
+
return if query.nil? || query.empty?
|
|
59
|
+
|
|
60
|
+
# Decode *before* scrubbing. `email=nik%40example.com` does not look like
|
|
61
|
+
# an email address until the `%40` is a `@`, and a redactor that can be
|
|
62
|
+
# defeated by percent-encoding is not a redactor. The decoded key is put
|
|
63
|
+
# back in front of the value before scrubbing, because `:password_pair`
|
|
64
|
+
# needs that context to recognise `hunter2` as a password at all.
|
|
65
|
+
pairs = parse_pairs(query).map { |key, value| [key, scrub_value(key, value)] }
|
|
66
|
+
|
|
67
|
+
env[ENV_PARAMS] = pairs.to_h
|
|
68
|
+
# Rebuilt from the decoded pairs, so this is a string for logs to print,
|
|
69
|
+
# not a query string to re-issue a request with.
|
|
70
|
+
env[ENV_QUERY] = pairs.map { |key, value| "#{key}=#{value}" }.join("&")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Deliberately not `Rack::Utils.parse_nested_query`: this is a logging aid,
|
|
74
|
+
# it must never raise on a malformed query string, and the gem must stay
|
|
75
|
+
# loadable without Rack.
|
|
76
|
+
def parse_pairs(query)
|
|
77
|
+
query.split("&").filter_map do |pair|
|
|
78
|
+
key, _, value = pair.partition("=")
|
|
79
|
+
next if key.empty?
|
|
80
|
+
|
|
81
|
+
[unescape(key), unescape(value)]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def scrub_value(key, value)
|
|
86
|
+
scrubbed = @scrubber.scrub("#{key}=#{value}")
|
|
87
|
+
prefix = "#{key}="
|
|
88
|
+
scrubbed.start_with?(prefix) ? scrubbed[prefix.length..] : scrubbed.partition("=").last
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unescape(str)
|
|
92
|
+
decoded = str.tr("+", " ").gsub(/%([0-9a-fA-F]{2})/) { [Regexp.last_match(1)].pack("H2") }
|
|
93
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
94
|
+
decoded.valid_encoding? ? decoded : decoded.force_encoding(Encoding::BINARY)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
str
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Rebuild the exception with a redacted message, keeping its class and
|
|
100
|
+
# backtrace so error groupers still group it the same way.
|
|
101
|
+
def redacted(error)
|
|
102
|
+
message = error.message
|
|
103
|
+
return error unless message.is_a?(String)
|
|
104
|
+
|
|
105
|
+
clean = @scrubber.scrub(message)
|
|
106
|
+
return error if clean == message
|
|
107
|
+
|
|
108
|
+
replacement = rebuild(error, clean)
|
|
109
|
+
return error if replacement.nil?
|
|
110
|
+
|
|
111
|
+
replacement.set_backtrace(error.backtrace) if error.backtrace
|
|
112
|
+
replacement
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Some exception classes have a non-standard `initialize` and cannot be
|
|
116
|
+
# rebuilt from a message. Better the original than a crash in the middleware
|
|
117
|
+
# that was supposed to protect you.
|
|
118
|
+
def rebuild(error, message)
|
|
119
|
+
error.exception(message)
|
|
120
|
+
rescue StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/scrubber_rb.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scrubber/version"
|
|
4
|
+
|
|
5
|
+
# Precompiled platform gems ship the extension under a Ruby ABI directory
|
|
6
|
+
# (lib/scrubber_rb/3.3/scrubber_rb.so); a source build puts it one level up.
|
|
7
|
+
begin
|
|
8
|
+
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
9
|
+
require_relative "scrubber_rb/#{Regexp.last_match(1)}/scrubber_rb"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
require_relative "scrubber_rb/scrubber_rb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "scrubber/match"
|
|
15
|
+
require_relative "scrubber/instance"
|
|
16
|
+
require_relative "scrubber/log_formatter"
|
|
17
|
+
require_relative "scrubber/middleware"
|
|
18
|
+
require_relative "scrubber/llm_guard"
|
|
19
|
+
|
|
20
|
+
# Fast PII and secret redaction, with the scanning done in Rust.
|
|
21
|
+
#
|
|
22
|
+
# Scrubber.scrub("contact nik@example.com") # => "contact [EMAIL]"
|
|
23
|
+
#
|
|
24
|
+
# The module-level methods are the convenient path and memoize one compiled
|
|
25
|
+
# engine per distinct configuration. For hot loops, build a {Scrubber::Instance}
|
|
26
|
+
# yourself with {Scrubber.new} and keep it around.
|
|
27
|
+
module Scrubber
|
|
28
|
+
# Detectors enabled unless you say otherwise. Sourced from the Rust registry
|
|
29
|
+
# so there is exactly one copy of this list in the project.
|
|
30
|
+
DEFAULTS = Native.default_detectors.map(&:to_sym).freeze
|
|
31
|
+
|
|
32
|
+
# Opt-in India pack, for DPDP Act workloads:
|
|
33
|
+
#
|
|
34
|
+
# Scrubber.new(detectors: Scrubber::DEFAULTS + Scrubber::INDIA)
|
|
35
|
+
INDIA = Native.india_detectors.map(&:to_sym).freeze
|
|
36
|
+
|
|
37
|
+
# Every detector this build knows about.
|
|
38
|
+
ALL = Native.all_detectors.map(&:to_sym).freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Build a reusable, frozen scrubber. See {Scrubber::Instance}.
|
|
42
|
+
def new(detectors: DEFAULTS, custom: {}, replacement: :label, hash_salt: nil)
|
|
43
|
+
Instance.new(
|
|
44
|
+
detectors: detectors, custom: custom,
|
|
45
|
+
replacement: replacement, hash_salt: hash_salt
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Redact `text` using a memoized engine for these options.
|
|
50
|
+
def scrub(text, **options)
|
|
51
|
+
engine(**options).scrub(text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Redact `text` in place.
|
|
55
|
+
def scrub!(text, **options)
|
|
56
|
+
engine(**options).scrub!(text)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Locate matches without replacing them. Returns {Scrubber::Match} structs.
|
|
60
|
+
def detect(text, **options)
|
|
61
|
+
engine(**options).detect(text)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# True if anything would be redacted.
|
|
65
|
+
def match?(text, **options)
|
|
66
|
+
engine(**options).match?(text)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Stream a file through the engine. Returns bytes written.
|
|
70
|
+
def scrub_file(input_path, output_path, **options)
|
|
71
|
+
chunk_size = options.delete(:chunk_size) || Instance::CHUNK_SIZE
|
|
72
|
+
engine(**options).scrub_file(input_path, output_path, chunk_size: chunk_size)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The memoized {Scrubber::Instance} for a set of options. Exposed because
|
|
76
|
+
# asking for it explicitly beats accidentally rebuilding one per request.
|
|
77
|
+
def engine(detectors: DEFAULTS, custom: {}, replacement: :label, hash_salt: nil)
|
|
78
|
+
key = Instance.cache_key(
|
|
79
|
+
detectors: detectors, custom: custom,
|
|
80
|
+
replacement: replacement, hash_salt: hash_salt
|
|
81
|
+
)
|
|
82
|
+
# Double-checked: reads are lock-free on the happy path, and building the
|
|
83
|
+
# same engine twice under a race is harmless (they are identical and
|
|
84
|
+
# immutable), so the mutex only prevents wasted work.
|
|
85
|
+
cache[key] || cache_mutex.synchronize do
|
|
86
|
+
cache[key] ||= new(
|
|
87
|
+
detectors: detectors, custom: custom,
|
|
88
|
+
replacement: replacement, hash_salt: hash_salt
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Drop memoized engines. Only useful in tests.
|
|
94
|
+
def reset!
|
|
95
|
+
cache_mutex.synchronize { cache.clear }
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def cache
|
|
102
|
+
@cache ||= {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cache_mutex
|
|
106
|
+
@cache_mutex ||= Mutex.new
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|