scrubber_rb 0.1.0-aarch64-linux
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/LICENSE +21 -0
- data/README.md +298 -0
- data/SECURITY.md +54 -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/3.1/scrubber_rb.so +0 -0
- data/lib/scrubber_rb/3.2/scrubber_rb.so +0 -0
- data/lib/scrubber_rb/3.3/scrubber_rb.so +0 -0
- data/lib/scrubber_rb/3.4/scrubber_rb.so +0 -0
- data/lib/scrubber_rb.rb +109 -0
- data/sig/scrubber_rb.rbs +150 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 38b7f7083ba67ffba5ffe8b11514f3155e3ce1a00ce5d7c560226ad989851297
|
|
4
|
+
data.tar.gz: 38cbd81c6131110fd9a59178f5ac4da72bd8a7e8a764b0b88c4c289d5c35acdf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0bc769a88fe5611ad89484aa0a43c90b698d8ca623a2aab72b44de57600771f8b36ba5a2efa855e8c02227ea06c2c1dff7b27ab25de63507ea9a35df538c13de
|
|
7
|
+
data.tar.gz: 02d2457b57e429711fb18f1576020a35b780179377736381c991743bca28a0b3c3ff077428fac1b4dab6946c12b841085c43cd70eb8511b5be1f5216b520180a
|
data/.cargo/config.toml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# musl targets static-link the C runtime by default, and a statically linked
|
|
2
|
+
# CRT cannot produce a shared library. Cargo decides whether a target supports
|
|
3
|
+
# `cdylib` *before* it invokes rustc, so the `-C target-feature=-crt-static`
|
|
4
|
+
# that rb_sys appends after `cargo rustc --` arrives too late:
|
|
5
|
+
#
|
|
6
|
+
# error: cannot produce cdylib for `scrubber_rb` as the target
|
|
7
|
+
# `x86_64-unknown-linux-musl` does not support these crate types
|
|
8
|
+
#
|
|
9
|
+
# Setting it here instead means cargo sees it while probing the target, decides
|
|
10
|
+
# cdylib is buildable, and the cross-compiled gem links. This also fixes source
|
|
11
|
+
# builds on Alpine, which is why the file ships inside the gem.
|
|
12
|
+
#
|
|
13
|
+
# Note the precedence rule: a `RUSTFLAGS` environment variable *replaces* these
|
|
14
|
+
# rather than merging with them. Anything that sets `RUSTFLAGS` for a musl build
|
|
15
|
+
# has to include `-C target-feature=-crt-static` itself.
|
|
16
|
+
|
|
17
|
+
[target.x86_64-unknown-linux-musl]
|
|
18
|
+
rustflags = ["-C", "target-feature=-crt-static"]
|
|
19
|
+
|
|
20
|
+
[target.aarch64-unknown-linux-musl]
|
|
21
|
+
rustflags = ["-C", "target-feature=-crt-static"]
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
First release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Rust scanning core** (magnus + rb-sys). Two-stage pass: one Aho-Corasick sweep over
|
|
17
|
+
literal anchors to eliminate rules that cannot match, then one `RegexSet` pass for the rest.
|
|
18
|
+
Overlaps resolved by leftmost, then most specific, then longest. Output built in a single
|
|
19
|
+
walk rather than repeated `gsub`.
|
|
20
|
+
- **14 default detectors**: `email`, `phone`, `credit_card`, `ssn`, `iban`, `ip`, `ipv6`,
|
|
21
|
+
`mac`, `jwt`, `aws_key`, `api_key`, `private_key`, `password_pair`, `url_credentials`.
|
|
22
|
+
- **Opt-in India pack** (`Scrubber::INDIA`): `phone_in`, `aadhaar`, `pan`, `upi`, for DPDP Act
|
|
23
|
+
workloads.
|
|
24
|
+
- **Checksum validation**: Luhn for cards, Verhoeff for Aadhaar, ISO 7064 mod-97 for IBAN
|
|
25
|
+
(plus an IBAN country-registry check), SSA range rules for SSNs, PAN entity-character check,
|
|
26
|
+
and a PSP allowlist for UPI handles.
|
|
27
|
+
- **19 provider-specific API key formats** under `:api_key` — GitHub, GitLab, Slack, Stripe,
|
|
28
|
+
OpenAI, Anthropic, Google, SendGrid, Twilio, npm, PyPI, Shopify, Square, Mailgun,
|
|
29
|
+
DigitalOcean, Telegram, AWS secret keys. No entropy heuristics.
|
|
30
|
+
- **Four replacement strategies**: `:label`, `:mask`, `:hash` (salted, deterministic, so logs
|
|
31
|
+
stay correlatable), `:remove`.
|
|
32
|
+
- **Custom detectors** from Ruby `Regexp`s, translated to Rust regex syntax. Backreferences,
|
|
33
|
+
lookaround, atomic groups and possessive quantifiers raise
|
|
34
|
+
`Scrubber::UnsupportedPatternError` at construction time, naming the construct — never
|
|
35
|
+
silent partial coverage.
|
|
36
|
+
- **`Scrubber::LogFormatter`** — wraps any Logger formatter.
|
|
37
|
+
- **`Scrubber::Middleware`** — Rack middleware that publishes redacted copies of the query
|
|
38
|
+
string and params at `env["scrubber.*"]` and redacts exception messages on the way out. It
|
|
39
|
+
deliberately does not rewrite the live request.
|
|
40
|
+
- **`Scrubber::LLMGuard`** — a plain callable that redacts prompts, message arrays and message
|
|
41
|
+
hashes before they reach a model API. Defaults to `:hash`.
|
|
42
|
+
- **`Scrubber.scrub_file`** — streams in 1MB chunks with an 8KB carry window, cutting at line
|
|
43
|
+
boundaries, with an extra guard so multi-line PEM blocks are never split.
|
|
44
|
+
- **GVL released** for inputs over 64KB, so large scans don't stall other threads.
|
|
45
|
+
- **Panic hygiene**: every FFI entry point is wrapped in `catch_unwind`; a Rust panic becomes
|
|
46
|
+
`Scrubber::InternalError` rather than a process abort.
|
|
47
|
+
- **Invalid UTF-8 tolerance**: valid regions are scrubbed, invalid bytes pass through
|
|
48
|
+
unchanged, nothing raises.
|
|
49
|
+
- **Character-accurate offsets** from `Scrubber#detect`, so spans index the Ruby string
|
|
50
|
+
correctly through emoji and Devanagari.
|
|
51
|
+
- **Benchmark suite** with an honest pure-Ruby reference implementing the identical detector
|
|
52
|
+
set, a seeded 100MB corpus generator, and a spec asserting the two implementations redact
|
|
53
|
+
identical spans.
|
|
54
|
+
- **Precompiled gems** for `x86_64-linux`, `x86_64-linux-musl`, `aarch64-linux`,
|
|
55
|
+
`aarch64-linux-musl`, `x86_64-darwin`, `arm64-darwin`, plus a best-effort
|
|
56
|
+
`x64-mingw-ucrt` and the source gem.
|
|
57
|
+
|
|
58
|
+
[Unreleased]: https://github.com/TheSoloHacker47/scrubber-rb/compare/v0.1.0...HEAD
|
|
59
|
+
[0.1.0]: https://github.com/TheSoloHacker47/scrubber-rb/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nikhil Nelson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# scrubber_rb
|
|
2
|
+
|
|
3
|
+
> Fast PII & secret redaction for Ruby — Rust-cored. Scrub logs, LLM prompts, and payloads before they leak.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/scrubber_rb)
|
|
6
|
+
[](https://github.com/TheSoloHacker47/scrubber-rb/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
Your production logs contain emails. Your error tracker captures request params with credit
|
|
10
|
+
cards in them. And now your app pipes user content straight into LLM APIs. `scrubber_rb`
|
|
11
|
+
redacts sensitive data from any string in a single pass through a Rust engine — fast enough
|
|
12
|
+
to sit in your logger, your Rack stack, and in front of every AI call.
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
Scrubber.scrub("contact nik@example.com, card 4111 1111 1111 1111, key AKIAIOSFODNN7EXAMPLE")
|
|
16
|
+
# => "contact [EMAIL], card [CREDIT_CARD], key [AWS_KEY]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Why this gem
|
|
20
|
+
|
|
21
|
+
- **Fast.** Multi-pattern scanning runs in Rust (`regex` + `aho-corasick`) — one pass, no
|
|
22
|
+
backtracking, GVL released on large inputs. Measured **38x** a pure-Ruby implementation of
|
|
23
|
+
the identical detector set on a 100MB log corpus, and **83x** on a single log line
|
|
24
|
+
([methodology below](#benchmarks), reproducible via `rake benchmark`).
|
|
25
|
+
- **Accurate.** Checksums, not just shapes: credit cards must pass Luhn, Aadhaar must pass
|
|
26
|
+
Verhoeff, IBANs must pass mod-97. A random 16-digit number won't get redacted.
|
|
27
|
+
- **Complete.** 18 detectors out of the box — emails, phones, cards, SSNs, IBANs, IPs, MACs,
|
|
28
|
+
JWTs, AWS keys, 19 provider-specific API key formats, private key blocks, `password=` pairs,
|
|
29
|
+
URL credentials — plus an opt-in India pack (Aadhaar, PAN, UPI) for DPDP Act compliance.
|
|
30
|
+
- **ReDoS-immune.** Rust's regex engine is linear-time by design, so attacker-controlled log
|
|
31
|
+
content can't blow up your CPU with pathological backtracking.
|
|
32
|
+
- **No toolchain needed.** Precompiled gems for Linux (x86_64/aarch64, glibc & musl) and
|
|
33
|
+
macOS (Intel & Apple Silicon). `gem install scrubber_rb` and you're done.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# Gemfile
|
|
39
|
+
gem "scrubber_rb"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Ruby >= 3.1. If no precompiled gem matches your platform, the source gem builds
|
|
43
|
+
automatically (needs a Rust toolchain).
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Basic
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Scrubber.scrub(text) # default detectors, [LABEL] replacement
|
|
51
|
+
Scrubber.detect(text) # => [#<Scrubber::Match type=:email 11...26 preview="n**@e******.com">]
|
|
52
|
+
Scrubber.match?(text) # => true/false, without building the redacted string
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Reusable instance (the fast path)
|
|
56
|
+
|
|
57
|
+
Compile once, scrub millions of times. Compiling ~45 patterns into one automaton is the
|
|
58
|
+
expensive part; scanning is cheap.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
SCRUBBER = Scrubber.new(
|
|
62
|
+
detectors: Scrubber::DEFAULTS + Scrubber::INDIA,
|
|
63
|
+
custom: { employee_id: /\bEMP-\d{6}\b/ },
|
|
64
|
+
replacement: :hash # deterministic tokens — logs stay correlatable
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
SCRUBBER.scrub("refund to nik@example.com") # => "refund to [EMAIL:1efaf05b]"
|
|
68
|
+
SCRUBBER.scrub("refund to nik@example.com") # => same token — grep still works
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Instances are frozen and hold no per-call state, so one instance is safe to share across every
|
|
72
|
+
thread in the process. The module-level `Scrubber.scrub` memoizes one engine per distinct
|
|
73
|
+
configuration, so it's fine too — the explicit instance just makes the lifetime obvious.
|
|
74
|
+
|
|
75
|
+
### Replacement strategies
|
|
76
|
+
|
|
77
|
+
| strategy | `nik@example.com` | `4111 1111 1111 1111` | `AKIA…EXAMPLE` |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| `:label` (default) | `[EMAIL]` | `[CREDIT_CARD]` | `[AWS_KEY]` |
|
|
80
|
+
| `:mask` | `n**@e******.com` | `**** **** **** 1111` | `********************` |
|
|
81
|
+
| `:hash` | `[EMAIL:1efaf05b]` | `[CREDIT_CARD:6a7e0e79]` | `[AWS_KEY:1a5d44a2]` |
|
|
82
|
+
| `:remove` | *(deleted)* | *(deleted)* | *(deleted)* |
|
|
83
|
+
|
|
84
|
+
`:mask` keeps the last four characters of *identifiers* so a human can still recognise the
|
|
85
|
+
record, and reveals nothing at all for *secrets* — the last four characters of an API key are
|
|
86
|
+
a leak, not a convenience.
|
|
87
|
+
|
|
88
|
+
`:hash` is `sha256(salt + value)[0, 8]`. Same input, same token, across processes and days, so
|
|
89
|
+
logs stay correlatable without holding the value. Pass `hash_salt:` to scope tokens per tenant
|
|
90
|
+
or per environment.
|
|
91
|
+
|
|
92
|
+
### Rails logs — one line
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# config/initializers/scrubber.rb
|
|
96
|
+
Rails.logger.formatter = Scrubber::LogFormatter.new(Rails.logger.formatter)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Wrapping the *formatter* rather than the logger catches everything: your own
|
|
100
|
+
`Rails.logger.info`, Active Record's SQL echo, Rack's request lines, and the exception messages
|
|
101
|
+
your error middleware logs on the way out.
|
|
102
|
+
|
|
103
|
+
### Rack middleware
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
use Scrubber::Middleware
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The middleware is deliberately **non-destructive**. It does not rewrite `QUERY_STRING`,
|
|
110
|
+
`rack.input`, or `params` — redacting the request your application is about to act on would
|
|
111
|
+
break every login form in the world, because the app needs the real password to check it.
|
|
112
|
+
What leaks is not the request, it's the *record* of the request.
|
|
113
|
+
|
|
114
|
+
So it publishes redacted copies for you to record, and cleans exceptions on the way out:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
env["scrubber.query_string"] # "user=nik&password=[PASSWORD_PAIR]"
|
|
118
|
+
env["scrubber.params"] # { "user" => "nik", "password" => "[PASSWORD_PAIR]" }
|
|
119
|
+
env["scrubber.instance"] # the engine, for anything else you want to log
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Any exception raised downstream is re-raised with a redacted message (same class, same
|
|
123
|
+
backtrace), so an error tracker that has never heard of this gem still gets a clean payload.
|
|
124
|
+
Turn that off with `use Scrubber::Middleware, scrub_exceptions: false`.
|
|
125
|
+
|
|
126
|
+
### Before your LLM calls
|
|
127
|
+
|
|
128
|
+
Stop shipping raw customer PII to third-party AI APIs:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
guard = Scrubber::LLMGuard.new # defaults to :hash
|
|
132
|
+
|
|
133
|
+
chat.ask(guard.call(user_message))
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`LLMGuard` is a plain callable that preserves the shape of what you give it — a String, a
|
|
137
|
+
message Array, or a message Hash — so it drops into RubyLLM, langchainrb, ruby-openai,
|
|
138
|
+
anthropic-sdk-ruby, or a hand-rolled `Net::HTTP` call the same way:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
messages = [
|
|
142
|
+
{ role: "system", content: "You are a support agent." },
|
|
143
|
+
{ role: "user", content: "my email is nik@example.com and my card is 4111 1111 1111 1111" }
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
client.chat(messages: guard.call(messages))
|
|
147
|
+
# => user content becomes "my email is [EMAIL:1efaf05b] and my card is [CREDIT_CARD:6a7e0e79]"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Deterministic `:hash` tokens mean the model can still refer to "the same email" across a
|
|
151
|
+
conversation without ever seeing it. `guard.findings(messages)` returns what *would* be
|
|
152
|
+
redacted, if you want a "we blocked N leaks today" metric or a test that fails when a prompt
|
|
153
|
+
builder regresses.
|
|
154
|
+
|
|
155
|
+
> **On RubyLLM:** as of August 2026 RubyLLM has no public middleware or interceptor hook, so
|
|
156
|
+
> this gem ships the wrapper pattern above rather than a plugin. Wrapping the argument at the
|
|
157
|
+
> call site is one line and doesn't depend on RubyLLM internals staying put.
|
|
158
|
+
|
|
159
|
+
### Big files
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
Scrubber.scrub_file("production.log", "production.scrubbed.log")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Streams in 1MB chunks with an 8KB carry window, cutting at line boundaries so a match spanning
|
|
166
|
+
a chunk boundary is never sliced in half (multi-line PEM blocks get an extra guard). Releases
|
|
167
|
+
the GVL for inputs over 64KB, so your Puma threads keep serving.
|
|
168
|
+
|
|
169
|
+
## Detectors
|
|
170
|
+
|
|
171
|
+
| Default | Opt-in (India pack) |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `email`, `phone`, `credit_card` (Luhn), `ssn`, `iban` (mod-97), `ip`, `ipv6`, `mac`, `jwt`, `aws_key`, `api_key`, `private_key`, `password_pair`, `url_credentials` | `phone_in`, `aadhaar` (Verhoeff), `pan`, `upi` |
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Scrubber.new(detectors: Scrubber::DEFAULTS + Scrubber::INDIA)
|
|
177
|
+
Scrubber.new(detectors: %i[email credit_card]) # narrow it down for hot paths
|
|
178
|
+
Scrubber::ALL # everything
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`:api_key` covers 19 provider-specific formats (GitHub, GitLab, Slack, Stripe, OpenAI,
|
|
182
|
+
Anthropic, Google, SendGrid, Twilio, npm, PyPI, Shopify, Square, Mailgun, DigitalOcean,
|
|
183
|
+
Telegram, AWS secret keys). All of them are structurally unambiguous vendor formats — there is
|
|
184
|
+
no entropy heuristic, because those false-positive on git SHAs and UUIDs.
|
|
185
|
+
|
|
186
|
+
Add your own with `custom: { name: /regexp/ }`. Ruby patterns are translated to Rust regex
|
|
187
|
+
syntax; unsupported constructs (backreferences, lookaround, atomic groups, possessive
|
|
188
|
+
quantifiers) raise `Scrubber::UnsupportedPatternError` at construction time, naming the
|
|
189
|
+
construct. You will never get silent partial coverage.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
Scrubber.new(custom: { dupe: /(\w)\1/ })
|
|
193
|
+
# => Scrubber::UnsupportedPatternError: custom detector "dupe" uses \1, which this engine
|
|
194
|
+
# cannot compile: backreferences require backtracking.
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Overlapping matches
|
|
198
|
+
|
|
199
|
+
When two detectors cover the same span, the more specific one wins: `password=ghp_…` reports
|
|
200
|
+
`[API_KEY]`, not `[PASSWORD_PAIR]`. Redaction is always the same either way; only the label
|
|
201
|
+
differs. Scrubbing already-scrubbed text is a no-op, so nested middleware won't corrupt lines.
|
|
202
|
+
|
|
203
|
+
## Benchmarks
|
|
204
|
+
|
|
205
|
+
<!-- BENCHMARK:START -->
|
|
206
|
+
<!-- generated by `rake benchmark`; do not edit by hand -->
|
|
207
|
+
|
|
208
|
+
| implementation | throughput | relative |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| scrubber_rb (Rust core) | **50.4 MB/s** | 1.0x |
|
|
211
|
+
| pure-Ruby reference (identical detectors) | 1.3 MB/s | 38.2x slower |
|
|
212
|
+
|
|
213
|
+
100MB synthetic log corpus (seed 42), all 14 default detectors, single thread, ruby 3.3.0 on arm64-darwin24.
|
|
214
|
+
Best of 2 passes. Reproduce with `rake benchmark`.
|
|
215
|
+
<!-- BENCHMARK:END -->
|
|
216
|
+
|
|
217
|
+
Per-line latency, which is what matters when this sits in your logger:
|
|
218
|
+
|
|
219
|
+
| implementation | one log line | relative |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| scrubber_rb | **1.24 µs** | 1.0x |
|
|
222
|
+
| pure-Ruby reference | 102.98 µs | 83x slower |
|
|
223
|
+
|
|
224
|
+
A Rust scan costs about a microsecond per log line, which is why it is safe to put on the
|
|
225
|
+
write path of every `Rails.logger` call.
|
|
226
|
+
|
|
227
|
+
**Methodology**, because a benchmark you can't audit is marketing:
|
|
228
|
+
|
|
229
|
+
- The baseline in `benchmark/pure_ruby_reference.rb` implements the **identical detector set**
|
|
230
|
+
— same patterns, same Luhn/Verhoeff/mod-97 checksums, same context validators, same
|
|
231
|
+
capture-group behaviour. It is written as good Ruby, not a strawman: one combined `Regexp`,
|
|
232
|
+
one `gsub` pass, no per-detector loop.
|
|
233
|
+
- `spec/benchmark_reference_spec.rb` asserts the two implementations **redact identical spans**
|
|
234
|
+
on every fixture and on a slice of the corpus. If someone tightens a Rust detector and
|
|
235
|
+
forgets the reference, the suite fails before anyone reads this table.
|
|
236
|
+
- The corpus is seeded synthetic production-shaped logs (`benchmark/corpus/generate.rb`), ~12%
|
|
237
|
+
of lines carrying something redactable. Raising that rate would flatter the Rust engine,
|
|
238
|
+
since pure Ruby is fastest on lines with no matches.
|
|
239
|
+
- Throughput is the best of N single-threaded full passes. CI enforces a 5x regression floor
|
|
240
|
+
(`rake benchmark:gate`) rather than restating the headline number, because shared runners
|
|
241
|
+
are noisy.
|
|
242
|
+
|
|
243
|
+
The difference is architectural, not micro-optimisation. Ruby's Onigmo is a backtracking
|
|
244
|
+
engine with no `RegexSet` and no Aho-Corasick prefilter, so every rule in the alternation is
|
|
245
|
+
live on every byte. The Rust core runs one Aho-Corasick pass over the literal anchors
|
|
246
|
+
(`AKIA`, `-----BEGIN`, `ghp_`, `password`) to eliminate roughly two thirds of the rules before
|
|
247
|
+
any regex runs, then one `RegexSet` pass for the rest.
|
|
248
|
+
|
|
249
|
+
`logstop` solves a narrower problem (log filtering, pure Ruby, zero native code) and solves it
|
|
250
|
+
well. If that's all you need, use it — it has no native extension to build. Run
|
|
251
|
+
`rake benchmark:logstop` for a scope-matched comparison.
|
|
252
|
+
|
|
253
|
+
## Security
|
|
254
|
+
|
|
255
|
+
- **ReDoS.** Rust's `regex` crate is a finite-automata engine with no backtracking, so match
|
|
256
|
+
time is linear in input length regardless of the pattern. Attacker-controlled log content
|
|
257
|
+
cannot pin a CPU. Custom Ruby patterns are checked at construction and rejected if they
|
|
258
|
+
require backtracking, so you can't reintroduce the problem through the back door.
|
|
259
|
+
- **Panics.** Every FFI entry point is wrapped in `catch_unwind`. A bug in the Rust core
|
|
260
|
+
becomes a `Scrubber::InternalError`, never a process abort.
|
|
261
|
+
- **Invalid UTF-8.** Scanning steps over invalid byte sequences and passes them through
|
|
262
|
+
unchanged, so a corrupt log line is scrubbed where it can be and never raises.
|
|
263
|
+
- **No network, no persistence, no telemetry.** It's a pure function library.
|
|
264
|
+
|
|
265
|
+
To report a vulnerability, see [SECURITY.md](SECURITY.md).
|
|
266
|
+
|
|
267
|
+
## What this gem does NOT do
|
|
268
|
+
|
|
269
|
+
Deterministic patterns can't catch free-form PII — **names, home addresses, medical notes**.
|
|
270
|
+
That's NER-model territory. We'd rather be honest about this than give you false confidence.
|
|
271
|
+
|
|
272
|
+
If your threat model includes free-text PII, layer a model-based pass behind this gem's fast
|
|
273
|
+
structural pass: [`top_secret`](https://github.com/thoughtbot/top_secret) and its
|
|
274
|
+
[`ruby_llm-top_secret`](https://github.com/thoughtbot/ruby_llm-top_secret) integration cover
|
|
275
|
+
that ground in Ruby, and Microsoft Presidio does in Python. Structural first (fast, exact),
|
|
276
|
+
model second (slow, fuzzy) is the right order.
|
|
277
|
+
|
|
278
|
+
## Compatibility
|
|
279
|
+
|
|
280
|
+
Ruby 3.1–3.4 · Linux (x86_64, aarch64; glibc & musl) · macOS (Intel, Apple Silicon) ·
|
|
281
|
+
Windows best-effort. CI runs the full matrix plus a weekly ruby-head canary.
|
|
282
|
+
|
|
283
|
+
## Contributing
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
git clone https://github.com/TheSoloHacker47/scrubber-rb && cd scrubber-rb
|
|
287
|
+
bin/setup # bundle install + rake compile
|
|
288
|
+
bundle exec rake # compile + spec
|
|
289
|
+
bundle exec rake ci # everything CI runs, in CI's order
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
New detector proposals are welcome — include public test vectors and a false-positive analysis
|
|
293
|
+
in the PR. See [CONTRIBUTING.md](CONTRIBUTING.md), and [docs/RELEASING.md](docs/RELEASING.md)
|
|
294
|
+
for how a release is cut.
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
[MIT](LICENSE).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
|---|---|
|
|
7
|
+
| 0.1.x | ✅ |
|
|
8
|
+
|
|
9
|
+
Until 1.0, security fixes land on the latest minor release.
|
|
10
|
+
|
|
11
|
+
## Reporting a vulnerability
|
|
12
|
+
|
|
13
|
+
Please **do not open a public issue.**
|
|
14
|
+
|
|
15
|
+
Use GitHub's private reporting:
|
|
16
|
+
[Report a vulnerability](https://github.com/TheSoloHacker47/scrubber-rb/security/advisories/new)
|
|
17
|
+
|
|
18
|
+
Or email **thesolohacker47@gmail.com**.
|
|
19
|
+
|
|
20
|
+
Please include a minimal reproduction and the version you're on. You'll get an
|
|
21
|
+
acknowledgement within 72 hours and an assessment within a week.
|
|
22
|
+
|
|
23
|
+
## What counts as a vulnerability here
|
|
24
|
+
|
|
25
|
+
This gem's job is to remove sensitive data from text. So:
|
|
26
|
+
|
|
27
|
+
- **A false negative is a security bug.** If input containing a credential or PII in a
|
|
28
|
+
supported format comes back unredacted, that's a vulnerability, not a feature request —
|
|
29
|
+
especially if the input is attacker-controlled and shaped to evade a detector (unusual
|
|
30
|
+
encodings, separator tricks, boundary manipulation).
|
|
31
|
+
- **A partial redaction is a security bug.** Output that leaves part of a secret visible.
|
|
32
|
+
- **A ReDoS or unbounded-resource path is a vulnerability.** The Rust `regex` crate is
|
|
33
|
+
linear-time, so this should be impossible for built-in detectors; if you find input that
|
|
34
|
+
makes scanning superlinear, we want to know.
|
|
35
|
+
- **A panic or crash across the FFI boundary is a vulnerability.** Every entry point is
|
|
36
|
+
wrapped in `catch_unwind` and should surface as `Scrubber::InternalError`. A process abort
|
|
37
|
+
means the wrapping failed.
|
|
38
|
+
- **Memory unsafety** in the native extension — use-after-free, out-of-bounds read, a data
|
|
39
|
+
race in the shared `Engine`.
|
|
40
|
+
|
|
41
|
+
## What does not count
|
|
42
|
+
|
|
43
|
+
- **Free-form PII going unredacted.** Names, street addresses, and medical notes are an
|
|
44
|
+
explicit non-goal, stated in the README. Deterministic patterns cannot find them; that needs
|
|
45
|
+
an NER model. This isn't a bug, it's the documented scope.
|
|
46
|
+
- **A false positive.** Over-redacting is annoying and we'll fix it, but it doesn't leak
|
|
47
|
+
anything. Open a normal issue.
|
|
48
|
+
- **Data your configuration didn't ask for.** If `:aadhaar` isn't in your `detectors:` list,
|
|
49
|
+
Aadhaar numbers won't be redacted. Check your configuration before reporting.
|
|
50
|
+
|
|
51
|
+
## When you report
|
|
52
|
+
|
|
53
|
+
Please redact your own reproduction input before sending it. If the input is a real credential,
|
|
54
|
+
rotate it first — a bug report is not a safe place for a live key.
|
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
data/sig/scrubber_rb.rbs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Type signatures for scrubber_rb.
|
|
2
|
+
|
|
3
|
+
module Scrubber
|
|
4
|
+
VERSION: String
|
|
5
|
+
|
|
6
|
+
# Detector keys, sourced from the Rust registry at load time.
|
|
7
|
+
DEFAULTS: Array[Symbol]
|
|
8
|
+
INDIA: Array[Symbol]
|
|
9
|
+
ALL: Array[Symbol]
|
|
10
|
+
|
|
11
|
+
type detector = Symbol | String
|
|
12
|
+
type replacement = :label | :mask | :hash | :remove
|
|
13
|
+
type custom_patterns = Hash[Symbol | String, Regexp]
|
|
14
|
+
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when a detector key is not in the registry.
|
|
19
|
+
class UnknownDetectorError < Error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raised at construction time when a custom Regexp needs backtracking.
|
|
23
|
+
class UnsupportedPatternError < Error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised for an invalid replacement strategy or malformed options.
|
|
27
|
+
class ConfigurationError < Error
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raised when a panic is caught at the FFI boundary. Always a bug.
|
|
31
|
+
class InternalError < Error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Match < Struct[untyped]
|
|
35
|
+
attr_accessor type: Symbol
|
|
36
|
+
attr_accessor begin: Integer
|
|
37
|
+
attr_accessor end: Integer
|
|
38
|
+
attr_accessor preview: String
|
|
39
|
+
|
|
40
|
+
def to_range: () -> Range[Integer]
|
|
41
|
+
def length: () -> Integer
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The compiled engine. Frozen; safe to share across threads.
|
|
45
|
+
class Instance
|
|
46
|
+
REPLACEMENTS: Array[Symbol]
|
|
47
|
+
CHUNK_SIZE: Integer
|
|
48
|
+
CARRY_SIZE: Integer
|
|
49
|
+
|
|
50
|
+
attr_reader detectors: Array[Symbol]
|
|
51
|
+
attr_reader custom: custom_patterns
|
|
52
|
+
attr_reader replacement: Symbol
|
|
53
|
+
attr_reader hash_salt: String?
|
|
54
|
+
|
|
55
|
+
def initialize: (
|
|
56
|
+
?detectors: Array[detector],
|
|
57
|
+
?custom: custom_patterns,
|
|
58
|
+
?replacement: replacement | String,
|
|
59
|
+
?hash_salt: String?
|
|
60
|
+
) -> void
|
|
61
|
+
|
|
62
|
+
def scrub: (String | _ToStr) -> String
|
|
63
|
+
def scrub!: (String) -> String
|
|
64
|
+
def detect: (String | _ToStr) -> Array[Match]
|
|
65
|
+
def match?: (String | _ToStr) -> bool
|
|
66
|
+
def scrub_file: (String | ::Pathname, String | ::Pathname, ?chunk_size: Integer) -> Integer
|
|
67
|
+
def rule_count: () -> Integer
|
|
68
|
+
def cache_key: () -> Array[untyped]
|
|
69
|
+
|
|
70
|
+
def self.cache_key: (
|
|
71
|
+
detectors: Array[detector],
|
|
72
|
+
custom: custom_patterns,
|
|
73
|
+
replacement: replacement | String,
|
|
74
|
+
hash_salt: String?
|
|
75
|
+
) -> Array[untyped]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Wraps a Logger formatter and redacts its output.
|
|
79
|
+
class LogFormatter
|
|
80
|
+
attr_reader scrubber: Instance
|
|
81
|
+
|
|
82
|
+
def initialize: (?untyped formatter, ?scrubber: Instance?, **untyped) -> void
|
|
83
|
+
def call: (String severity, Time time, untyped progname, untyped msg) -> untyped
|
|
84
|
+
def self.default_formatter: () -> untyped
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Rack middleware. Non-destructive: publishes redacted copies at env["scrubber.*"].
|
|
88
|
+
class Middleware
|
|
89
|
+
QUERY_STRING: String
|
|
90
|
+
ENV_QUERY: String
|
|
91
|
+
ENV_PARAMS: String
|
|
92
|
+
ENV_INSTANCE: String
|
|
93
|
+
|
|
94
|
+
attr_reader scrubber: Instance
|
|
95
|
+
|
|
96
|
+
def initialize: (
|
|
97
|
+
untyped app,
|
|
98
|
+
?scrubber: Instance?,
|
|
99
|
+
?scrub_exceptions: bool,
|
|
100
|
+
**untyped
|
|
101
|
+
) -> void
|
|
102
|
+
def call: (Hash[String, untyped] env) -> untyped
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Redacts prompts before they reach a model API.
|
|
106
|
+
class LLMGuard
|
|
107
|
+
CONTENT_KEYS: Array[String]
|
|
108
|
+
|
|
109
|
+
attr_reader scrubber: Instance
|
|
110
|
+
|
|
111
|
+
def initialize: (?scrubber: Instance?, ?replacement: replacement, **untyped) -> void
|
|
112
|
+
def call: (untyped) -> untyped
|
|
113
|
+
alias scrub call
|
|
114
|
+
def to_proc: () -> Proc
|
|
115
|
+
def findings: (untyped) -> Array[Match]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The native extension. Not a public API; use Scrubber::Instance.
|
|
119
|
+
class Native
|
|
120
|
+
def self.new: (
|
|
121
|
+
Array[String] detectors,
|
|
122
|
+
Array[[String, String, Integer]] customs,
|
|
123
|
+
String replacement,
|
|
124
|
+
String? hash_salt
|
|
125
|
+
) -> Native
|
|
126
|
+
|
|
127
|
+
def self.default_detectors: () -> Array[String]
|
|
128
|
+
def self.india_detectors: () -> Array[String]
|
|
129
|
+
def self.all_detectors: () -> Array[String]
|
|
130
|
+
|
|
131
|
+
def scrub: (String) -> String?
|
|
132
|
+
def detect: (String, bool) -> Array[[String, Integer, Integer, String]]
|
|
133
|
+
def rule_count: () -> Integer
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.new: (
|
|
137
|
+
?detectors: Array[detector],
|
|
138
|
+
?custom: custom_patterns,
|
|
139
|
+
?replacement: replacement | String,
|
|
140
|
+
?hash_salt: String?
|
|
141
|
+
) -> Instance
|
|
142
|
+
|
|
143
|
+
def self.scrub: (String | _ToStr, **untyped) -> String
|
|
144
|
+
def self.scrub!: (String, **untyped) -> String
|
|
145
|
+
def self.detect: (String | _ToStr, **untyped) -> Array[Match]
|
|
146
|
+
def self.match?: (String | _ToStr, **untyped) -> bool
|
|
147
|
+
def self.scrub_file: (String | ::Pathname, String | ::Pathname, **untyped) -> Integer
|
|
148
|
+
def self.engine: (**untyped) -> Instance
|
|
149
|
+
def self.reset!: () -> nil
|
|
150
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: scrubber_rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: aarch64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- Nikhil Nelson
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Redact emails, phone numbers, credit cards, SSNs, IBANs, IPs, JWTs, API
|
|
14
|
+
keys, private keys and Indian IDs (Aadhaar/PAN/UPI) from any string in a single
|
|
15
|
+
pass through a Rust engine. Checksum-validated (Luhn, Verhoeff, mod-97) so random
|
|
16
|
+
numbers are not mistaken for card numbers, and ReDoS-immune by construction. Ships
|
|
17
|
+
a Rails log formatter, Rack middleware and an LLM prompt guard.
|
|
18
|
+
email:
|
|
19
|
+
- thesolohacker47@gmail.com
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- ".cargo/config.toml"
|
|
25
|
+
- CHANGELOG.md
|
|
26
|
+
- LICENSE
|
|
27
|
+
- README.md
|
|
28
|
+
- SECURITY.md
|
|
29
|
+
- lib/scrubber/instance.rb
|
|
30
|
+
- lib/scrubber/llm_guard.rb
|
|
31
|
+
- lib/scrubber/log_formatter.rb
|
|
32
|
+
- lib/scrubber/match.rb
|
|
33
|
+
- lib/scrubber/middleware.rb
|
|
34
|
+
- lib/scrubber/version.rb
|
|
35
|
+
- lib/scrubber_rb.rb
|
|
36
|
+
- lib/scrubber_rb/3.1/scrubber_rb.so
|
|
37
|
+
- lib/scrubber_rb/3.2/scrubber_rb.so
|
|
38
|
+
- lib/scrubber_rb/3.3/scrubber_rb.so
|
|
39
|
+
- lib/scrubber_rb/3.4/scrubber_rb.so
|
|
40
|
+
- sig/scrubber_rb.rbs
|
|
41
|
+
homepage: https://github.com/TheSoloHacker47/scrubber-rb
|
|
42
|
+
licenses:
|
|
43
|
+
- MIT
|
|
44
|
+
metadata:
|
|
45
|
+
homepage_uri: https://github.com/TheSoloHacker47/scrubber-rb
|
|
46
|
+
source_code_uri: https://github.com/TheSoloHacker47/scrubber-rb
|
|
47
|
+
bug_tracker_uri: https://github.com/TheSoloHacker47/scrubber-rb/issues
|
|
48
|
+
changelog_uri: https://github.com/TheSoloHacker47/scrubber-rb/blob/main/CHANGELOG.md
|
|
49
|
+
documentation_uri: https://github.com/TheSoloHacker47/scrubber-rb#readme
|
|
50
|
+
rubygems_mfa_required: 'true'
|
|
51
|
+
post_install_message:
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.1'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 3.5.dev
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 3.3.11
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.5.23
|
|
70
|
+
signing_key:
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Fast PII and secret redaction for Ruby, with a Rust core.
|
|
73
|
+
test_files: []
|