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
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,41 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "scrubber_rb"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
authors = ["Nikhil Nelson <thesolohacker47@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
description = "Rust core for the scrubber_rb Ruby gem: fast PII and secret redaction"
|
|
8
|
+
repository = "https://github.com/TheSoloHacker47/scrubber-rb"
|
|
9
|
+
publish = false
|
|
10
|
+
rust-version = "1.74"
|
|
11
|
+
|
|
12
|
+
[lib]
|
|
13
|
+
name = "scrubber_rb"
|
|
14
|
+
# cdylib only. Nothing links against this crate as a library: the unit tests
|
|
15
|
+
# live in `#[cfg(test)] mod tests` blocks, which `cargo test` compiles into its
|
|
16
|
+
# own harness whatever the crate-type is.
|
|
17
|
+
#
|
|
18
|
+
# Keeping the list to exactly what is needed also keeps failures loud. With
|
|
19
|
+
# "rlib" alongside it, a musl target that cannot build a cdylib silently
|
|
20
|
+
# produced only the rlib and reported success, and the build failed several
|
|
21
|
+
# steps later with a missing-file error that said nothing about the cause.
|
|
22
|
+
crate-type = ["cdylib"]
|
|
23
|
+
|
|
24
|
+
[features]
|
|
25
|
+
# Link libruby into the built artifacts so `cargo test` can run the pure-Rust
|
|
26
|
+
# unit tests from a plain executable. The shipped extension never enables this:
|
|
27
|
+
# it is loaded *into* a Ruby process, which already has those symbols.
|
|
28
|
+
link-ruby = ["rb-sys/link-ruby"]
|
|
29
|
+
|
|
30
|
+
[dependencies]
|
|
31
|
+
magnus = "0.7"
|
|
32
|
+
rb-sys = "0.9"
|
|
33
|
+
regex = "1.10"
|
|
34
|
+
aho-corasick = "1.1"
|
|
35
|
+
sha2 = "0.10"
|
|
36
|
+
|
|
37
|
+
[build-dependencies]
|
|
38
|
+
rb-sys-env = "0.1"
|
|
39
|
+
|
|
40
|
+
[dev-dependencies]
|
|
41
|
+
proptest = "1"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
//! Checksum validators.
|
|
2
|
+
//!
|
|
3
|
+
//! These are what separate `scrubber_rb` from a pile of regexes: a 16-digit
|
|
4
|
+
//! number is only redacted as a credit card if it actually passes Luhn, and a
|
|
5
|
+
//! 12-digit number is only an Aadhaar if it passes Verhoeff. Shapes lie;
|
|
6
|
+
//! checksums do not.
|
|
7
|
+
|
|
8
|
+
/// Luhn (mod-10) checksum, used by credit / debit card PANs.
|
|
9
|
+
///
|
|
10
|
+
/// Accepts an iterator of ASCII digit characters; non-digits must already be
|
|
11
|
+
/// stripped by the caller.
|
|
12
|
+
pub fn luhn(digits: &[u8]) -> bool {
|
|
13
|
+
if digits.len() < 12 {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
let mut sum: u32 = 0;
|
|
17
|
+
// Double every second digit counting from the right.
|
|
18
|
+
for (i, d) in digits.iter().rev().enumerate() {
|
|
19
|
+
let mut v = u32::from(*d);
|
|
20
|
+
if i % 2 == 1 {
|
|
21
|
+
v *= 2;
|
|
22
|
+
if v > 9 {
|
|
23
|
+
v -= 9;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
sum += v;
|
|
27
|
+
}
|
|
28
|
+
sum % 10 == 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Verhoeff tables. `D` is the dihedral group D5 multiplication table, `P` the
|
|
32
|
+
// permutation table, `INV` the multiplicative inverse table.
|
|
33
|
+
#[rustfmt::skip]
|
|
34
|
+
const VERHOEFF_D: [[u8; 10]; 10] = [
|
|
35
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
36
|
+
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
|
37
|
+
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
|
38
|
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
|
39
|
+
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
|
40
|
+
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
|
41
|
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
|
42
|
+
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
|
43
|
+
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
|
44
|
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
#[rustfmt::skip]
|
|
48
|
+
const VERHOEFF_P: [[u8; 10]; 8] = [
|
|
49
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
50
|
+
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
|
51
|
+
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
|
52
|
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
|
53
|
+
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
|
54
|
+
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
|
55
|
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
|
56
|
+
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/// Verhoeff checksum, used by the Indian Aadhaar number.
|
|
60
|
+
pub fn verhoeff(digits: &[u8]) -> bool {
|
|
61
|
+
let mut c: u8 = 0;
|
|
62
|
+
for (i, d) in digits.iter().rev().enumerate() {
|
|
63
|
+
if *d > 9 {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
c = VERHOEFF_D[c as usize][VERHOEFF_P[i % 8][*d as usize] as usize];
|
|
67
|
+
}
|
|
68
|
+
c == 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// ISO 7064 mod-97-10, used by IBAN account numbers.
|
|
72
|
+
///
|
|
73
|
+
/// `s` is the raw IBAN with separators already stripped and letters uppercased.
|
|
74
|
+
pub fn iban_mod97(s: &[u8]) -> bool {
|
|
75
|
+
if s.len() < 15 || s.len() > 34 {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
// Move the first four characters to the end, then map A-Z to 10-35 and take
|
|
79
|
+
// the whole thing mod 97 incrementally (the number is far too big for u128).
|
|
80
|
+
let rotated = s[4..].iter().chain(s[..4].iter());
|
|
81
|
+
let mut rem: u32 = 0;
|
|
82
|
+
for ch in rotated {
|
|
83
|
+
let val = match ch {
|
|
84
|
+
b'0'..=b'9' => u32::from(ch - b'0'),
|
|
85
|
+
b'A'..=b'Z' => u32::from(ch - b'A') + 10,
|
|
86
|
+
_ => return false,
|
|
87
|
+
};
|
|
88
|
+
// Feed one or two decimal digits depending on the mapped value.
|
|
89
|
+
if val >= 10 {
|
|
90
|
+
rem = (rem * 100 + val) % 97;
|
|
91
|
+
} else {
|
|
92
|
+
rem = (rem * 10 + val) % 97;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
rem == 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[cfg(test)]
|
|
99
|
+
mod tests {
|
|
100
|
+
use super::*;
|
|
101
|
+
|
|
102
|
+
fn d(s: &str) -> Vec<u8> {
|
|
103
|
+
s.bytes()
|
|
104
|
+
.filter(u8::is_ascii_digit)
|
|
105
|
+
.map(|b| b - b'0')
|
|
106
|
+
.collect()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[test]
|
|
110
|
+
fn luhn_known_good_vectors() {
|
|
111
|
+
// Publicly published test card numbers.
|
|
112
|
+
for good in [
|
|
113
|
+
"4111111111111111", // Visa
|
|
114
|
+
"4012888888881881", // Visa
|
|
115
|
+
"5500005555555559", // Mastercard
|
|
116
|
+
"5105105105105100", // Mastercard
|
|
117
|
+
"378282246310005", // Amex
|
|
118
|
+
"371449635398431", // Amex
|
|
119
|
+
"6011111111111117", // Discover
|
|
120
|
+
"3530111333300000", // JCB
|
|
121
|
+
] {
|
|
122
|
+
assert!(luhn(&d(good)), "{good} should pass Luhn");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn luhn_rejects_bad_check_digits() {
|
|
128
|
+
for bad in [
|
|
129
|
+
"4111111111111112",
|
|
130
|
+
"1234567890123456",
|
|
131
|
+
"0000000000000001",
|
|
132
|
+
"5500005555555558",
|
|
133
|
+
] {
|
|
134
|
+
assert!(!luhn(&d(bad)), "{bad} should fail Luhn");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn luhn_rejects_short_input() {
|
|
140
|
+
assert!(!luhn(&d("12345678901")));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Multiplicative inverse table for D5, used only to *generate* valid test
|
|
144
|
+
// vectors so the tests don't depend on hand-copied numbers.
|
|
145
|
+
const INV: [u8; 10] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
|
|
146
|
+
|
|
147
|
+
fn with_check_digit(payload: &str) -> Vec<u8> {
|
|
148
|
+
let mut digits = d(payload);
|
|
149
|
+
let mut c: u8 = 0;
|
|
150
|
+
for (i, dg) in digits.iter().rev().enumerate() {
|
|
151
|
+
c = VERHOEFF_D[c as usize][VERHOEFF_P[(i + 1) % 8][*dg as usize] as usize];
|
|
152
|
+
}
|
|
153
|
+
digits.push(INV[c as usize]);
|
|
154
|
+
digits
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn verhoeff_known_vectors() {
|
|
159
|
+
// Verhoeff's own published example: the check digit for 236 is 3.
|
|
160
|
+
assert!(verhoeff(&d("2363")));
|
|
161
|
+
assert!(!verhoeff(&d("2364")));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn verhoeff_accepts_generated_check_digits() {
|
|
166
|
+
for payload in ["23678901234", "99988877766", "20000000000", "61234567890"] {
|
|
167
|
+
let full = with_check_digit(payload);
|
|
168
|
+
assert_eq!(full.len(), 12);
|
|
169
|
+
assert!(verhoeff(&full), "{payload} + check digit should validate");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn verhoeff_rejects_transpositions_and_single_digit_errors() {
|
|
175
|
+
// Verhoeff catches all single-digit errors and all adjacent transpositions.
|
|
176
|
+
let base = with_check_digit("23678901234");
|
|
177
|
+
assert!(verhoeff(&base));
|
|
178
|
+
for i in 0..base.len() - 1 {
|
|
179
|
+
if base[i] == base[i + 1] {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let mut swapped = base.clone();
|
|
183
|
+
swapped.swap(i, i + 1);
|
|
184
|
+
assert!(!verhoeff(&swapped), "transposition at {i} should fail");
|
|
185
|
+
}
|
|
186
|
+
for i in 0..base.len() {
|
|
187
|
+
let mut bumped = base.clone();
|
|
188
|
+
bumped[i] = (bumped[i] + 1) % 10;
|
|
189
|
+
assert!(!verhoeff(&bumped), "single-digit error at {i} should fail");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#[test]
|
|
194
|
+
fn iban_known_vectors() {
|
|
195
|
+
for good in [
|
|
196
|
+
"GB82WEST12345698765432",
|
|
197
|
+
"DE89370400440532013000",
|
|
198
|
+
"FR1420041010050500013M02606",
|
|
199
|
+
"NL91ABNA0417164300",
|
|
200
|
+
"CH9300762011623852957",
|
|
201
|
+
] {
|
|
202
|
+
assert!(iban_mod97(good.as_bytes()), "{good} should pass mod-97");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[test]
|
|
207
|
+
fn iban_rejects_bad_checksums() {
|
|
208
|
+
for bad in [
|
|
209
|
+
"GB82WEST12345698765431",
|
|
210
|
+
"DE89370400440532013001",
|
|
211
|
+
"XX00NOTANIBANATALL0000",
|
|
212
|
+
] {
|
|
213
|
+
assert!(!iban_mod97(bad.as_bytes()), "{bad} should fail mod-97");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[test]
|
|
218
|
+
fn iban_rejects_out_of_range_lengths() {
|
|
219
|
+
assert!(!iban_mod97(b"GB82WEST"));
|
|
220
|
+
assert!(!iban_mod97(&[b'A'; 40]));
|
|
221
|
+
}
|
|
222
|
+
}
|