data_redactor 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -1
- data/{readme.md → README.md} +57 -6
- data/ext/data_redactor/custom_patterns.c +5 -0
- data/ext/data_redactor/data_redactor.c +15 -1
- data/ext/data_redactor/matcher.c +1193 -0
- data/ext/data_redactor/matcher.h +78 -0
- data/ext/data_redactor/patterns.c +125 -2
- data/ext/data_redactor/patterns.h +11 -0
- data/ext/data_redactor/redact.c +106 -33
- data/ext/data_redactor/scan.c +141 -92
- data/lib/data_redactor/version.rb +1 -1
- data/lib/data_redactor.rb +75 -1
- metadata +32 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e744f5e18d5ce2311c21197b6d47b20d6032a38a72c8bfb2287a80219b8e2d77
|
|
4
|
+
data.tar.gz: cfd314afec1d018175a8424d81c12958582d5ddd9c16d05baa6048c17bba8ca8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7ea267c8927f9852621180d77530818980af4fc5c089b7db95e6b0f980c1f3cb09c11806e9459241ca363b5c06203f34ede5e2b1473b1a2046a9a4e37e63fbb
|
|
7
|
+
data.tar.gz: 4081b898b339423bb5dd06f557e32bea5c7242869fc61a6b0a2447053ddcec28a751768b83d1ba66a2fcb82755ceb654c6e95052bc01e2cb23e1fac4b44b8d37
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.1] - 2026-06-10
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **musl/Alpine load failure** — the `hashicorp_vault_batch_token` pattern used a
|
|
14
|
+
`{138,300}` interval whose upper bound exceeds POSIX `RE_DUP_MAX` (255). glibc
|
|
15
|
+
accepts it, but musl's `regcomp` rejects it ("Invalid contents of {}"), so the
|
|
16
|
+
native musl gem raised at load (`require "data_redactor"`) on Alpine. Capped the
|
|
17
|
+
bound at 255; tokens are still neutralized (prefix + 251+ chars redacted).
|
|
18
|
+
|
|
19
|
+
## [0.10.0] - 2026-06-09
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Engine rewrite (v19 hybrid)** — `redact` and `scan` now run through a
|
|
23
|
+
Thompson NFA → bytecode → lazy-DFA multi-pattern engine (v19) for all 88
|
|
24
|
+
built-in patterns, replacing the previous per-pattern POSIX `regexec` loop.
|
|
25
|
+
Custom patterns (`add_pattern`) continue to use the glibc path (hybrid split
|
|
26
|
+
— required for correct UTF-8 multibyte character-class matching in user regex).
|
|
27
|
+
- Throughput on a 1 MB log: **~8.4× faster** than the previous C engine
|
|
28
|
+
(0.87 i/s → 7.27 i/s); **2.25× faster** than pure-Ruby `gsub` (was 4×
|
|
29
|
+
slower). Small per-call strings: 1.7–2.3× faster (was 3–4.6× slower).
|
|
30
|
+
- Overlap resolution: built-in matches are now resolved by an index-order
|
|
31
|
+
greedy claim (`mm_resolve`) that reproduces today's sequential per-pattern
|
|
32
|
+
rewrite semantics exactly. The one accepted divergence (rewrite-created
|
|
33
|
+
boundary when two secrets abut with no separator) is documented in
|
|
34
|
+
`TODO.md §1d` and pinned by `DIVERGENCE` specs.
|
|
35
|
+
- `rb_data_redactor_scan`: coordinate mapping (`repl_log` / `WORKING_TO_ORIG`)
|
|
36
|
+
replaced by direct original-frame offset emission from the v19 engine; custom
|
|
37
|
+
patterns use a lightweight offset-walk over the built-in event list.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **Swiss AHV false-negative** — boundary-wrapped patterns with a
|
|
41
|
+
start-anchored required literal now correctly set `max_back = 1` (not 0) so
|
|
42
|
+
the literal-skip does not overshoot the boundary byte. `756.1234.5678.90`
|
|
43
|
+
now matches as expected. (Pre-existing bug in the old engine, caught by
|
|
44
|
+
going live.)
|
|
45
|
+
|
|
10
46
|
## [0.9.0] - 2026-05-22
|
|
11
47
|
|
|
12
48
|
### Added
|
|
@@ -177,7 +213,9 @@ features as 0.7.1 plus the pipeline fix.
|
|
|
177
213
|
- `DataRedactor.redact(text)` module function returning the input with every match replaced by `[REDACTED]`.
|
|
178
214
|
- RSpec suite with one example per pattern.
|
|
179
215
|
|
|
180
|
-
[Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.
|
|
216
|
+
[Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.10.1...HEAD
|
|
217
|
+
[0.10.1]: https://github.com/danielefrisanco/data_redactor/compare/v0.10.0...v0.10.1
|
|
218
|
+
[0.10.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.9.0...v0.10.0
|
|
181
219
|
[0.9.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.8.0...v0.9.0
|
|
182
220
|
[0.8.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.2...v0.8.0
|
|
183
221
|
[0.7.2]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.1...v0.7.2
|
data/{readme.md → README.md}
RENAMED
|
@@ -10,9 +10,10 @@ A Ruby gem with a C extension for high-performance regex-based redaction of sens
|
|
|
10
10
|
|
|
11
11
|
DataRedactor scans text for sensitive data — API keys and cloud secrets, IBANs,
|
|
12
12
|
credit cards, national IDs, emails, phone numbers, IPs, and more — and replaces
|
|
13
|
-
each match with a placeholder. The scanning runs in a C extension backed by
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
each match with a placeholder. The scanning runs in a C extension backed by a
|
|
14
|
+
zero-dependency Thompson NFA → lazy-DFA multi-pattern engine (v19) that scans
|
|
15
|
+
all 88 built-in patterns in a single pass — 2–2.5× faster than pure-Ruby `gsub`
|
|
16
|
+
on large payloads, with no external library dependencies.
|
|
16
17
|
|
|
17
18
|
It ships **88 built-in patterns** across 15+ countries, grouped into tags
|
|
18
19
|
(`:credentials`, `:financial`, `:contact`, ...) so you can redact only what you
|
|
@@ -384,8 +385,18 @@ redactor/
|
|
|
384
385
|
│ ├── scan.{c,h} # _scan + byte-offset replacement-log macros
|
|
385
386
|
│ ├── custom_patterns.{c,h} # Dynamic registry: add/remove/clear/list
|
|
386
387
|
│ └── tags.h # TAG_* bit constants
|
|
387
|
-
|
|
388
|
-
|
|
388
|
+
├── spec/
|
|
389
|
+
│ └── data_redactor_spec.rb # RSpec tests — at least one example per pattern, plus filter / placeholder / custom-pattern coverage
|
|
390
|
+
├── benchmark/ # Repo-only perf scripts (not packaged in the gem)
|
|
391
|
+
│ ├── README.md # How to run, what each script measures
|
|
392
|
+
│ ├── support/corpus.rb # Shared payload builders + pure-Ruby baseline redactor
|
|
393
|
+
│ ├── throughput.rb # MB/s on representative payloads
|
|
394
|
+
│ ├── vs_pure_ruby.rb # C extension vs pure-Ruby gsub (same 88 patterns)
|
|
395
|
+
│ ├── scaling.rb # Runtime vs input size 1KB → 50MB
|
|
396
|
+
│ └── per_pattern.rb # Per-pattern scan cost
|
|
397
|
+
└── docs/ # Design and execution docs for future work
|
|
398
|
+
├── standalone_matcher_design.md
|
|
399
|
+
└── combined_matcher_plan.md
|
|
389
400
|
```
|
|
390
401
|
|
|
391
402
|
## Requirements
|
|
@@ -460,6 +471,45 @@ Or compile and test in one step:
|
|
|
460
471
|
bundle exec rake
|
|
461
472
|
```
|
|
462
473
|
|
|
474
|
+
## Benchmarks
|
|
475
|
+
|
|
476
|
+
The `benchmark/` directory holds four scripts that measure the C engine under
|
|
477
|
+
different angles. They are **not** packaged with the gem.
|
|
478
|
+
|
|
479
|
+
```bash
|
|
480
|
+
bundle install # pulls benchmark-ips, benchmark-memory (dev deps)
|
|
481
|
+
bundle exec rake compile
|
|
482
|
+
bundle exec ruby benchmark/vs_pure_ruby.rb # head-to-head vs pure-Ruby gsub, same 88 patterns
|
|
483
|
+
bundle exec ruby benchmark/throughput.rb # MB/s on a log line, JSON, 1MB and 10MB log files
|
|
484
|
+
bundle exec ruby benchmark/scaling.rb # runtime vs input size (1KB → 50MB), confirms linear scaling
|
|
485
|
+
bundle exec ruby benchmark/per_pattern.rb # per-pattern scan cost over a 1MB payload
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
See [`benchmark/README.md`](benchmark/README.md) for what each script measures
|
|
489
|
+
and how the pure-Ruby baseline is kept honest (it reads the same patterns the
|
|
490
|
+
C engine uses, via `DataRedactor::BUILTIN_PATTERN_SOURCES`).
|
|
491
|
+
|
|
492
|
+
### Performance (0.10.0 — v19 multi-pattern engine)
|
|
493
|
+
|
|
494
|
+
As of 0.10.0 the C extension runs a **Thompson NFA → lazy-DFA multi-pattern
|
|
495
|
+
engine** (v19) that scans the input once across all 88 built-in patterns,
|
|
496
|
+
with two selective-merge passes (pure-digit group + IBAN union) that further
|
|
497
|
+
reduce work for the most common pattern classes. Custom patterns (`add_pattern`)
|
|
498
|
+
still use the glibc path (required for correct UTF-8 diacritic matching).
|
|
499
|
+
|
|
500
|
+
| Payload | v19 engine (0.10.0) | Pure-Ruby `gsub` | Ratio |
|
|
501
|
+
|-----------------------|---------------------|------------------|-----------------|
|
|
502
|
+
| log line (168 B) | 41 µs / call | 71 µs / call | **1.7× faster** |
|
|
503
|
+
| JSON blob (~580 B) | 81 µs / call | 132 µs / call | **1.6× faster** |
|
|
504
|
+
| 8 log lines (1.3 KB) | 175 µs / call | 399 µs / call | **2.3× faster** |
|
|
505
|
+
| 100 log lines (17 KB) | 2.0 ms / call | 4.6 ms / call | **2.3× faster** |
|
|
506
|
+
| 1 MB log | 138 ms / call | 294 ms / call | **2.1× faster** |
|
|
507
|
+
| 10 MB log | 1.44 s / call | — | 6.9 MB/s |
|
|
508
|
+
|
|
509
|
+
All payload sizes pass a correctness check (redaction count matches pure-Ruby `gsub`).
|
|
510
|
+
The previous engine (per-pattern `regexec`) was **4.25× slower** than pure Ruby on the
|
|
511
|
+
1 MB payload — a ~9× swing. Old numbers are in git history (`CHANGELOG.md` [0.9.0]).
|
|
512
|
+
|
|
463
513
|
## How it works
|
|
464
514
|
|
|
465
515
|
1. At load time, `Init_data_redactor` compiles all 85 regex patterns once using `regcomp` (POSIX ERE) and stores them as static `regex_t` structs. Patterns marked as boundary-wrapped are expanded with `wrap_boundary()` before compilation.
|
|
@@ -473,7 +523,7 @@ All C-side buffers are heap-allocated with `malloc`/`strdup` and freed before th
|
|
|
473
523
|
|
|
474
524
|
## Thread safety
|
|
475
525
|
|
|
476
|
-
`DataRedactor.redact` and `DataRedactor.scan` are safe to call concurrently from multiple threads.
|
|
526
|
+
`DataRedactor.redact` and `DataRedactor.scan` are safe to call concurrently from multiple threads. The v19 engine holds MRI's GVL for the duration of each call (no `rb_thread_call_without_gvl`), so concurrent calls are serialised by the GVL. Each call allocates its own working buffers; built-in engine state is read-only after `mm_init()` at load time.
|
|
477
527
|
|
|
478
528
|
`DataRedactor.add_pattern`, `remove_pattern`, and `clear_custom_patterns!` mutate a shared dynamic array and are **not** thread-safe. Register custom patterns once at boot — before spawning worker threads or forking — and they will be visible (read-only) to every subsequent `redact`/`scan` call.
|
|
479
529
|
|
|
@@ -490,3 +540,4 @@ Released under the [MIT License](LICENSE).
|
|
|
490
540
|
- **Pattern ordering matters** — patterns run sequentially. An early broad pattern (e.g. the 9-digit passport) may consume digits that a later pattern (e.g. credit card) depends on. Boundary wrapping mitigates this for pure-digit patterns.
|
|
491
541
|
- **AWS Secret Key (pattern 1)** — 40 consecutive base64 characters is a broad match. It can produce false positives in base64-encoded content such as embedded images or binary blobs.
|
|
492
542
|
- **Duplicate digit patterns** — several national ID formats share the same digit-length (11 digits: PESEL, Norwegian Fødselsnummer, Belgian National Number). They are kept as separate slots for clarity but the practical effect is that any 11-digit boundary-delimited number will be redacted.
|
|
543
|
+
- **Single-pass overlap semantics** — built-in patterns are resolved by an index-order greedy claim: the lower-index pattern wins any region it matches. When two secrets abut with no separator, a rewrite-created word boundary can cause the second to be missed. This is rare in real text (secrets are almost always separator-delimited) and will be fixed by the upcoming longest-match-wins resolver in 1.0.
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
#include <string.h>
|
|
4
4
|
#include <stdlib.h>
|
|
5
5
|
|
|
6
|
+
/* Custom patterns deliberately do NOT use the v19 engine: they keep the glibc
|
|
7
|
+
* regexec path (replace_all_matches), because user regex can contain multibyte
|
|
8
|
+
* UTF-8 inside character classes (e.g. name_pattern's [oOòóô…]) that the
|
|
9
|
+
* byte-level v19 engine cannot match the way glibc's locale-aware matcher does.
|
|
10
|
+
* See TODO.md §1d Gap 2 for the hybrid-split rationale. */
|
|
6
11
|
custom_pattern_t *custom_patterns = NULL;
|
|
7
12
|
int custom_count = 0;
|
|
8
13
|
int custom_cap = 0;
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
#include "redact.h"
|
|
8
8
|
#include "scan.h"
|
|
9
9
|
#include "custom_patterns.h"
|
|
10
|
+
#include "matcher.h"
|
|
10
11
|
|
|
11
12
|
void Init_data_redactor(void) {
|
|
13
|
+
/* Build the v19 multi-pattern engines from the gem's pattern arrays. */
|
|
14
|
+
mm_init();
|
|
15
|
+
|
|
12
16
|
/* Compile all built-in regex patterns at load time. */
|
|
13
17
|
for (int i = 0; i < NUM_PATTERNS; i++) {
|
|
14
18
|
const char *pat;
|
|
@@ -43,15 +47,25 @@ void Init_data_redactor(void) {
|
|
|
43
47
|
rb_define_module_function(mDataRedactor, "_clear_custom_patterns", rb_clear_custom_patterns, 0);
|
|
44
48
|
rb_define_module_function(mDataRedactor, "_custom_patterns", rb_custom_patterns, 0);
|
|
45
49
|
|
|
46
|
-
/* Frozen
|
|
50
|
+
/* Frozen introspection arrays, parallel to the pattern table (same index = same
|
|
51
|
+
* pattern). NAMES/TAG_BITS back only:/except: validation; SOURCES/BOUNDARY are
|
|
52
|
+
* internal aids (the benchmark suite rebuilds the patterns in pure Ruby from
|
|
53
|
+
* them). SOURCES holds the unwrapped POSIX ERE — boundary wrapping is applied
|
|
54
|
+
* above at compile time for patterns with BOUNDARY[i] == true. */
|
|
47
55
|
VALUE builtin_names = rb_ary_new_capa(NUM_PATTERNS);
|
|
48
56
|
VALUE builtin_tag_bits = rb_ary_new_capa(NUM_PATTERNS);
|
|
57
|
+
VALUE builtin_sources = rb_ary_new_capa(NUM_PATTERNS);
|
|
58
|
+
VALUE builtin_boundary = rb_ary_new_capa(NUM_PATTERNS);
|
|
49
59
|
for (int i = 0; i < NUM_PATTERNS; i++) {
|
|
50
60
|
rb_ary_push(builtin_names, rb_str_new_frozen(rb_str_new_cstr(pattern_names[i])));
|
|
51
61
|
rb_ary_push(builtin_tag_bits, INT2NUM(pattern_tags[i]));
|
|
62
|
+
rb_ary_push(builtin_sources, rb_str_new_frozen(rb_str_new_cstr(pattern_strings[i])));
|
|
63
|
+
rb_ary_push(builtin_boundary, boundary_wrapped[i] ? Qtrue : Qfalse);
|
|
52
64
|
}
|
|
53
65
|
rb_define_const(mDataRedactor, "BUILTIN_PATTERN_NAMES", rb_ary_freeze(builtin_names));
|
|
54
66
|
rb_define_const(mDataRedactor, "BUILTIN_PATTERN_TAG_BITS", rb_ary_freeze(builtin_tag_bits));
|
|
67
|
+
rb_define_const(mDataRedactor, "BUILTIN_PATTERN_SOURCES", rb_ary_freeze(builtin_sources));
|
|
68
|
+
rb_define_const(mDataRedactor, "BUILTIN_PATTERN_BOUNDARY", rb_ary_freeze(builtin_boundary));
|
|
55
69
|
|
|
56
70
|
/* Placeholder mode constants. */
|
|
57
71
|
rb_define_const(mDataRedactor, "PH_MODE_PLAIN", INT2NUM(PLACEHOLDER_MODE_PLAIN));
|