data_redactor 0.14.0 → 0.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b29290519836ca25d5188a5ef4da2585bd7f11faa0c072927863c637fb618eeb
4
- data.tar.gz: 465091099d2fcf4b990d4e4259c3c4ad549588839d918d831c9747236f84e864
3
+ metadata.gz: 62a1726acf7948f272e0e04bc44dac5068063731953cc7500803f2e1f8c052ed
4
+ data.tar.gz: 64d9f0fb75ef197facaf6538a4c46c5dad11a37e196e8b37de83ba2e629aeb24
5
5
  SHA512:
6
- metadata.gz: fbc51cb331674163af43d4e952bce6ec936db4e3235ca356082a83211ae552d84409bebcdffcb364c09ed8099504ac8418d2fffed3d273d4392d762d99098d59
7
- data.tar.gz: e57d9545b5acec4ca25c1c5a30b1987d3d9769f027725e6613f396e7b2bedbe352620278a6cb8c613d5e1a1c1ecabb0e446bf2a4b6ae0339bedf8a4563a33b01
6
+ metadata.gz: ae2923599925002c945f3a488e8eafac91037075a88815975a3c920cddc195732f4186fca51dd6b98485a60e84d9ef2d55fb1fa439e9d88a3203c8f4cd6b9f75
7
+ data.tar.gz: c8f6691b41a419dd5adfbe89b0399d4624f3ae06b7c5a10fdffcbaee774b2009567ba687ab65b46aa0554dcbb31408212c3ebe7bef66dfb892f19191776fa8fe
data/CHANGELOG.md CHANGED
@@ -7,7 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.14.0] - 2026-06-17
10
+ ## [0.16.0] - 2026-06-21
11
+
12
+ ### Added
13
+ - **Opt-in `#redact` refinements.** `require "data_redactor/refinements"` then
14
+ `using DataRedactor::Refinements` adds `#redact` to `String` (→
15
+ `DataRedactor.redact`) and to `Hash`/`Array` (→ `DataRedactor.redact_deep`), e.g.
16
+ `"email a@b.com".redact` and `chat.ask(user_input.redact)`. Refinements are
17
+ lexically scoped, so they never pollute the core classes globally — apps that
18
+ don't opt in are unaffected and there is no collision risk. Forwards
19
+ `only:`/`except:`/`placeholder:`; never mutates the receiver.
20
+ `DataRedactor.redact` remains the primary API.
21
+ - **Length-aware placeholder modes.** `placeholder: :length` replaces each match
22
+ with `[REDACTED:N]` and `placeholder: :tagged_length` with `[REDACTED:TAGNAME:N]`,
23
+ where `N` is the **byte length** of the redacted value. Readers can gauge what
24
+ was there without seeing it. Both compose with `only:`/`except:` and are
25
+ forwarded by `redact_deep`, `redact_json`, and the integrations. Additive —
26
+ two new values for the existing `placeholder:` keyword; no behaviour changes.
27
+ - **CI: ASan/UBSan memory-safety gate.** New job builds the matcher engine
28
+ standalone under `-fsanitize=address,undefined` and drives it over an
29
+ adversarial corpus + seeded fuzz loop (catches the `OP_EOL`-class OOB read).
30
+ - **CI: throughput-trend history + PR comment.** New job records the C/pure-Ruby
31
+ ratio over time (history in `actions/cache`, no gh-pages), posts a sticky PR
32
+ comment comparing each run to the previous point, and fails on a >10% drop.
33
+
34
+ ## [0.15.0] - 2026-06-17
35
+
36
+ ### Changed
37
+ - **Overlap resolution is now longest-match-wins** (was earlier-index-wins). When
38
+ two patterns match overlapping spans, the engine keeps the **longer** span;
39
+ equal-length ties go to the lower pattern index (preserving prior behaviour for
40
+ same-length matches). The previous "earliest pattern by index wins any region it
41
+ can match" semantic was an accidental by-product of sequential per-pattern
42
+ rewriting, and it could leave a secret **partly unredacted** — e.g.
43
+ `AKIA…EXAMPLE` followed by 20 more alphanumeric bytes used to redact only the
44
+ 20-char access-key prefix and leak the trailing 20 bytes; it now redacts the full
45
+ 40-char secret. The public API (`redact`, `scan`) is unchanged; `scan` may report
46
+ one longer match where it previously reported several shorter overlapping ones.
47
+ Aligns with Onigmo/PCRE/RE2/Hyperscan semantics. Resolver only — no measurable
48
+ throughput change (still ~2.4× over pure-Ruby on the 1 MB log).
49
+
50
+ ### Added
51
+ - **CI throughput regression gate** (`throughput-gate` job). Runs
52
+ `benchmark/ci_throughput_gate.rb`, which gates on the ratio of the C engine to
53
+ a pure-Ruby gsub loop over the same patterns (the ratio cancels CI-runner
54
+ speed variance, unlike absolute MB/s). Loose floor (1.5×; known result
55
+ ~2.25×), informational throughput output, plus a correctness guard so an
56
+ engine that redacts less cannot pass as "faster". Repo/CI only — not packaged.
57
+
58
+ ## [0.14.1] - 2026-06-17
59
+
60
+ ### Changed
61
+ - **Bounded the greedy tails of seven built-in token patterns** (`jwt`,
62
+ `grafana_api_token`, `ssh_public_key`, `bearer_token`, `anthropic_api_key`,
63
+ `openai_project_api_key`, `sendgrid_api_key`). Open-ended quantifiers (`+` and
64
+ `{n,}`) are capped at the POSIX `RE_DUP_MAX` of 255 (`{n,255}`), matching the
65
+ existing `hashicorp_vault_batch_token` precedent. A token is unusable once its
66
+ front is redacted, so a bounded prefix is sufficient to neutralize it. This
67
+ restores a finite `max_len` for these patterns (re-enabling the engine's
68
+ literal back-up skip) and removes a theoretical O(N²) worst case where a
69
+ crafted prefix plus a megabyte of matching characters forces a long greedy
70
+ scan. Tokens longer than 255 characters are still neutralized — only a
71
+ cryptographically-dead tail may remain.
11
72
 
12
73
  ### Added
13
74
  - **Key-name-anchored secret redaction** (`:credentials`). A new pattern tier
@@ -275,7 +336,10 @@ features as 0.7.1 plus the pipeline fix.
275
336
  - `DataRedactor.redact(text)` module function returning the input with every match replaced by `[REDACTED]`.
276
337
  - RSpec suite with one example per pattern.
277
338
 
278
- [Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.14.0...HEAD
339
+ [Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.16.0...HEAD
340
+ [0.16.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.15.0...v0.16.0
341
+ [0.15.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.14.1...v0.15.0
342
+ [0.14.1]: https://github.com/danielefrisanco/data_redactor/compare/v0.14.0...v0.14.1
279
343
  [0.14.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.13.0...v0.14.0
280
344
  [0.13.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.11.0...v0.13.0
281
345
  [0.11.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.10.1...v0.11.0
data/README.md CHANGED
@@ -6,16 +6,22 @@
6
6
 
7
7
  A Ruby gem with a C extension for high-performance regex-based redaction of sensitive data from strings.
8
8
 
9
+ > 📄 The engineering behind the v19 matching engine is written up as an experience
10
+ > report, *"The Fastest Engine Is Not the Shippable Engine: Replacing a Regex Engine
11
+ > for Data Redaction Under Production Constraints,"* currently under review at
12
+ > *Software: Practice and Experience* (Manuscript ID 7985366). Source and the
13
+ > reproducibility bundle are in [`paper/`](paper/).
14
+
9
15
  ## What it does
10
16
 
11
17
  DataRedactor scans text for sensitive data — API keys and cloud secrets, IBANs,
12
18
  credit cards, national IDs, emails, phone numbers, IPs, and more — and replaces
13
19
  each match with a placeholder. The scanning runs in a C extension backed by a
14
20
  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`
21
+ every built-in pattern in a single pass — 2–2.5× faster than pure-Ruby `gsub`
16
22
  on large payloads, with no external library dependencies.
17
23
 
18
- It ships **88 built-in patterns** across 15+ countries, grouped into tags
24
+ It ships **89 built-in patterns** across 15+ countries, grouped into tags
19
25
  (`:credentials`, `:financial`, `:contact`, ...) so you can redact only what you
20
26
  care about. Beyond plain strings it can walk nested Hashes, Arrays, and JSON,
21
27
  audit a payload without mutating it (`scan`), and plug into Logger, Rails, and
@@ -103,9 +109,18 @@ DataRedactor.redact(text, placeholder: :hash)
103
109
  # "user@example.com" → "[CONTACT_3d7a]"
104
110
  # "user@example.com" → "[CONTACT_3d7a]" (same every time)
105
111
  # "other@example.com" → "[CONTACT_91fc]" (different value, different hash)
112
+
113
+ # Length — embeds the byte length of the redacted value, so readers can
114
+ # gauge what was there without seeing it.
115
+ DataRedactor.redact(text, placeholder: :length)
116
+ # "user@example.com" → "[REDACTED:16]"
117
+
118
+ # Tagged length — tag name plus byte length.
119
+ DataRedactor.redact(text, placeholder: :tagged_length)
120
+ # "user@example.com" → "[REDACTED:CONTACT:16]"
106
121
  ```
107
122
 
108
- All three modes compose with `only:` and `except:`:
123
+ All modes compose with `only:` and `except:`:
109
124
 
110
125
  ```ruby
111
126
  DataRedactor.redact(text, only: :contact, placeholder: :tagged)
@@ -165,6 +180,24 @@ safe_json = DataRedactor.redact_json('{"email":"alice@example.com","count":3}')
165
180
  DataRedactor.redact_json("not json") # => JSON::ParserError
166
181
  ```
167
182
 
183
+ ### `#redact` refinements (opt-in)
184
+
185
+ Prefer `"text".redact` over `DataRedactor.redact("text")`? Opt into the refinement. It adds `#redact` to `String` (via `redact`) and to `Hash`/`Array` (via `redact_deep`) **only in the files that `using` it** — refinements are lexically scoped, so the core classes are never monkey-patched globally and there is no collision risk for apps that don't opt in. `DataRedactor.redact` remains the primary API.
186
+
187
+ ```ruby
188
+ require "data_redactor/refinements"
189
+ using DataRedactor::Refinements
190
+
191
+ "email alice@example.com".redact # => "email [REDACTED]"
192
+ { token: "AKIAIOSFODNN7EXAMPLE" }.redact # => { token: "[REDACTED]" }
193
+ ["a@b.com", 3].redact # => ["[REDACTED]", 3]
194
+
195
+ # Handy right before sending text to an LLM:
196
+ chat.ask(user_input.redact)
197
+ ```
198
+
199
+ `#redact` forwards `only:`/`except:`/`placeholder:` and never mutates the receiver. Without `using DataRedactor::Refinements` in the current file, `#redact` is not defined.
200
+
168
201
  ### Custom patterns
169
202
 
170
203
  Teams often have internal IDs that the gem can't ship. Register them at boot — or at runtime from any thread (registration is thread-safe, see [Thread safety](#thread-safety)):
@@ -309,7 +342,25 @@ safe_response = DataRedactor::Integrations::OpenAI.redact_response(response)
309
342
 
310
343
  `content` may be a plain String or an array of content blocks/parts (`{ type: "text", text: "..." }`) — only the `text` of `text` blocks is redacted; image and other block types pass through untouched. For Claude, a top-level `system:` String is also redacted; for OpenAI, a `{ role: "system" }` message in the array is redacted like any other. Pass a bare `messages` array or the whole request Hash (with a `messages` key) — either works.
311
344
 
312
- ## Detected patterns (88 total)
345
+ ### RubyLLM
346
+
347
+ [RubyLLM](https://rubyllm.com) is a unified Ruby client for every major LLM provider — and a perfect match for `data_redactor`: anything you send to a model is exactly the kind of free text that leaks secrets and PII. Because RubyLLM takes plain strings, you can scrub them with `DataRedactor.redact` before they leave the process — no extra integration required:
348
+
349
+ ```ruby
350
+ require "ruby_llm"
351
+ require "data_redactor"
352
+
353
+ chat = RubyLLM.chat(model: "claude-opus-4-8")
354
+ chat.with_instructions(DataRedactor.redact("You are a support agent for ACME Corp."))
355
+
356
+ user_input = "My card is 4111 1111 1111 1111 and my email is alice@example.com"
357
+ chat.ask(DataRedactor.redact(user_input))
358
+ # the model receives: "My card is [REDACTED] and my email is [REDACTED]"
359
+ ```
360
+
361
+ Wrap each prompt (and any `with_instructions` system prompt) in `DataRedactor.redact` before passing it to `ask`. This is a per-call step you opt into — RubyLLM does not yet expose a request hook for automatic, transparent redaction of every outbound call ([crmne/ruby_llm#765](https://github.com/crmne/ruby_llm/issues/765) tracks the connection-middleware hook that would enable it).
362
+
363
+ ## Detected patterns (89 total)
313
364
 
314
365
  The table below is a representative sample. Use `DataRedactor.pattern_names` for the canonical, machine-readable list — it stays in sync with the C extension automatically.
315
366
 
@@ -435,7 +486,7 @@ redactor/
435
486
  │ ├── README.md # How to run, what each script measures
436
487
  │ ├── support/corpus.rb # Shared payload builders + pure-Ruby baseline redactor
437
488
  │ ├── throughput.rb # MB/s on representative payloads
438
- │ ├── vs_pure_ruby.rb # C extension vs pure-Ruby gsub (same 88 patterns)
489
+ │ ├── vs_pure_ruby.rb # C extension vs pure-Ruby gsub (same patterns)
439
490
  │ ├── scaling.rb # Runtime vs input size 1KB → 50MB
440
491
  │ └── per_pattern.rb # Per-pattern scan cost
441
492
  └── docs/ # Design and execution docs for future work
@@ -523,7 +574,7 @@ different angles. They are **not** packaged with the gem.
523
574
  ```bash
524
575
  bundle install # pulls benchmark-ips, benchmark-memory (dev deps)
525
576
  bundle exec rake compile
526
- bundle exec ruby benchmark/vs_pure_ruby.rb # head-to-head vs pure-Ruby gsub, same 88 patterns
577
+ bundle exec ruby benchmark/vs_pure_ruby.rb # head-to-head vs pure-Ruby gsub, same patterns
527
578
  bundle exec ruby benchmark/throughput.rb # MB/s on a log line, JSON, 1MB and 10MB log files
528
579
  bundle exec ruby benchmark/scaling.rb # runtime vs input size (1KB → 50MB), confirms linear scaling
529
580
  bundle exec ruby benchmark/per_pattern.rb # per-pattern scan cost over a 1MB payload
@@ -535,11 +586,8 @@ C engine uses, via `DataRedactor::BUILTIN_PATTERN_SOURCES`).
535
586
 
536
587
  ### Performance (0.10.0 — v19 multi-pattern engine)
537
588
 
538
- As of 0.10.0 the C extension runs a **Thompson NFA lazy-DFA multi-pattern
539
- engine** (v19) that scans the input once across all 88 built-in patterns,
540
- with two selective-merge passes (pure-digit group + IBAN union) that further
541
- reduce work for the most common pattern classes. Custom patterns (`add_pattern`)
542
- still use the glibc path (required for correct UTF-8 diacritic matching).
589
+ Measured on the v19 engine ([How it works](#how-it-works)) vs a pure-Ruby `gsub`
590
+ loop over the same patterns:
543
591
 
544
592
  | Payload | v19 engine (0.10.0) | Pure-Ruby `gsub` | Ratio |
545
593
  |-----------------------|---------------------|------------------|-----------------|
@@ -576,14 +624,14 @@ machine-dependent, but the flat curve is not.
576
624
 
577
625
  ## How it works
578
626
 
579
- 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.
580
- 2. `DataRedactor.redact(text)` receives a Ruby `String`, converts it to a C `char*` via `StringValueCStr`, and runs each compiled pattern in sequence on a working buffer.
581
- 3. For each pattern, `replace_all_matches` iterates using `regexec`, copies non-matching segments to a fresh output buffer, and inserts `[REDACTED]` in place of each match. For boundary-wrapped patterns, `regexec` is called with `nmatch=4` and sub-match groups `[1]`/`[3]` identify the boundary characters so they are preserved verbatim.
582
- 4. The output buffer is grown with `realloc` as needed. After all patterns are applied the result is returned as a Ruby `String` via `rb_str_new_cstr`. All intermediate `malloc`/`strdup` allocations are explicitly `free`d.
627
+ 1. At load time, `mm_init()` compiles every built-in pattern from a Thompson NFA into bytecode, lazily building each pattern's DFA on first use (interned and cached). Boundary-wrapped patterns are expanded with the word-boundary group before compilation.
628
+ 2. `DataRedactor.redact(text)` / `scan(text)` hand the input to the v19 engine, which scans it **once** and emits `(pattern_id, start, length)` events for every enabled pattern. Two selective-merge passes (a pure-digit group and an IBAN union) collapse the most common pattern classes into shared scans. The single pass over the original buffer is what makes the engine O(N).
629
+ 3. The raw events are resolved by `mm_resolve` under the **longest-match-wins** policy: overlapping spans are reduced to a non-overlapping set keeping the longest match at each position, with the lower pattern index breaking equal-length ties.
630
+ 4. `redact` rewrites the surviving spans to placeholders in one buffer build (preserving the boundary characters of boundary-wrapped matches); `scan` returns the event list with byte offsets into the original string. Custom patterns (`add_pattern`) run on the glibc `regexec` path afterward required for correct UTF-8 diacritic matching.
583
631
 
584
632
  ## Memory management
585
633
 
586
- All C-side buffers are heap-allocated with `malloc`/`strdup` and freed before the function returns. The only Ruby-managed allocation is the final return value from `rb_str_new_cstr`. No Ruby objects are created mid-processing, so GC cannot collect anything out from under the C code.
634
+ All C-side working buffers are heap-allocated and freed before the call returns; the only Ruby-managed allocation is the final result `String`. No Ruby objects are created mid-scan, so GC cannot collect anything out from under the C code. Per-thread engine scratch (NFA state, lazy-DFA cache) is freed automatically when the thread exits — see [Thread safety](#thread-safety).
587
635
 
588
636
  ## Thread safety
589
637
 
@@ -601,7 +649,6 @@ Released under the [MIT License](LICENSE).
601
649
 
602
650
  ## Known limitations
603
651
 
604
- - **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.
605
652
  - **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.
606
653
  - **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.
607
- - **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.
654
+ - **Overlap resolution is longest-match-wins** — when two patterns match overlapping spans the engine keeps the longer span; equal-length ties go to the lower pattern index. This favours redacting *more* when uncertain (a 40-char secret is redacted whole rather than leaking the bytes past a shorter prefix match). When two secrets abut with **no separator** between them, a boundary-wrapped pattern can fail to match because the original buffer has no word boundary where one token meets the next, leaving the abutting token unredacted. This is rare in real text (secrets are almost always separator-delimited).
@@ -68,9 +68,11 @@ void Init_data_redactor(void) {
68
68
  rb_define_const(mDataRedactor, "BUILTIN_PATTERN_BOUNDARY", rb_ary_freeze(builtin_boundary));
69
69
 
70
70
  /* Placeholder mode constants. */
71
- rb_define_const(mDataRedactor, "PH_MODE_PLAIN", INT2NUM(PLACEHOLDER_MODE_PLAIN));
72
- rb_define_const(mDataRedactor, "PH_MODE_TAGGED", INT2NUM(PLACEHOLDER_MODE_TAGGED));
73
- rb_define_const(mDataRedactor, "PH_MODE_HASH", INT2NUM(PLACEHOLDER_MODE_HASH));
71
+ rb_define_const(mDataRedactor, "PH_MODE_PLAIN", INT2NUM(PLACEHOLDER_MODE_PLAIN));
72
+ rb_define_const(mDataRedactor, "PH_MODE_TAGGED", INT2NUM(PLACEHOLDER_MODE_TAGGED));
73
+ rb_define_const(mDataRedactor, "PH_MODE_HASH", INT2NUM(PLACEHOLDER_MODE_HASH));
74
+ rb_define_const(mDataRedactor, "PH_MODE_LENGTH", INT2NUM(PLACEHOLDER_MODE_LENGTH));
75
+ rb_define_const(mDataRedactor, "PH_MODE_TAGGED_LENGTH", INT2NUM(PLACEHOLDER_MODE_TAGGED_LENGTH));
74
76
 
75
77
  /* Tag bitmask values used by the Ruby wrapper to build only/except masks. */
76
78
  rb_define_const(mDataRedactor, "TAG_CREDENTIALS", INT2NUM(TAG_CREDENTIALS));
@@ -16,10 +16,10 @@
16
16
  *
17
17
  * 2. Output contract. mm_scan() takes an enable_bits gate and emits ORIGINAL-
18
18
  * frame (pattern_id, start, span) events for ALL enabled patterns in one
19
- * pass; it does NOT model the gem's cross-pattern sequential rewrite. The
20
- * caller applies mm_resolve() (index-order greedy claim) to reproduce
21
- * today's "earlier-index pattern wins" semantics byte-for-byte. See
22
- * TODO.md §1d Gap 5 and the AKIA specs in spec/data_redactor_spec.rb.
19
+ * pass. The caller applies mm_resolve() (longest-match-wins greedy claim:
20
+ * longest span at each position wins, equal lengths broken by lower
21
+ * pattern_id) to pick the final non-overlapping set. See TODO.md §1d Gap 5
22
+ * and the overlap-resolution specs in spec/data_redactor_spec.rb.
23
23
  *
24
24
  * The infix-literal classification and the BM_INFIX hint table below are ported
25
25
  * from prototypes/multi_pattern_matcher/gen_patterns.rb (which derived them from the
@@ -1260,14 +1260,14 @@ size_t mm_scan(const char *input, size_t len,
1260
1260
  return count;
1261
1261
  }
1262
1262
 
1263
- /* Order events for the index-order greedy claim: ascending pattern_id, then
1264
- * ascending start (so a lower-index pattern always gets first claim on a region;
1265
- * within a pattern, earlier matches are seen first). */
1263
+ /* Order events for the longest-match-wins greedy claim: ascending start, then
1264
+ * descending length (so the longest span at a given start is seen first), then
1265
+ * ascending pattern_id (lower index wins a tie of equal length). */
1266
1266
  static int ev_cmp_resolve(const void *a, const void *b) {
1267
1267
  const mm_match_t *x = a, *y = b;
1268
- if (x->pattern_id != y->pattern_id) return x->pattern_id - y->pattern_id;
1269
1268
  if (x->start != y->start) return x->start < y->start ? -1 : 1;
1270
- return 0;
1269
+ if (x->length != y->length) return x->length > y->length ? -1 : 1;
1270
+ return x->pattern_id - y->pattern_id;
1271
1271
  }
1272
1272
 
1273
1273
  /* Order kept events for emission: ascending start. */
@@ -1281,12 +1281,12 @@ size_t mm_resolve(mm_match_t *ev, size_t n) {
1281
1281
  if (n == 0) return 0;
1282
1282
  qsort(ev, n, sizeof(mm_match_t), ev_cmp_resolve);
1283
1283
 
1284
- /* Greedy claim in (pattern_id, start) order. An event is kept iff its span
1285
- * [start, start+length) does not overlap any already-kept span. Kept spans
1286
- * are accumulated in `kept`; we check membership against them. n is small
1287
- * for typical inputs, but to stay linear-ish we keep `kept` sorted by start
1288
- * and binary-search the neighbourhood. For simplicity and because match
1289
- * counts are modest, a linear overlap check against the kept set is used. */
1284
+ /* Greedy claim in (start, -length, pattern_id) order: the longest span at
1285
+ * each position is offered first and claims its region; any later (shorter,
1286
+ * or equal-length higher-id) event overlapping an already-kept span is
1287
+ * dropped. An event is kept iff its span [start, start+length) does not
1288
+ * overlap any already-kept span. Match counts are modest, so a linear
1289
+ * overlap check against the kept set is used. */
1290
1290
  mm_match_t *kept = mm_xmalloc(n * sizeof(mm_match_t));
1291
1291
  size_t nk = 0;
1292
1292
  for (size_t i = 0; i < n; i++) {
@@ -53,19 +53,20 @@ void mm_clear_custom(void);
53
53
  * array disables out-of-range patterns. Events carry ORIGINAL-frame offsets.
54
54
  *
55
55
  * Events are NOT pre-resolved for cross-pattern overlap — the caller applies
56
- * the index-order greedy claim (mm_resolve) to reproduce the gem's sequential
57
- * per-pattern rewrite semantics.
56
+ * the longest-match-wins greedy claim (mm_resolve) to pick the final
57
+ * non-overlapping set.
58
58
  */
59
59
  size_t mm_scan(const char *input, size_t len,
60
60
  const int *enable_bits, size_t n_bits,
61
61
  mm_match_t *out, size_t max);
62
62
 
63
63
  /*
64
- * Resolve raw scan events into the non-overlapping set the gem's sequential
65
- * per-pattern rewrite would produce: in (pattern_id, start) order, keep an
66
- * event iff its CORE span does not overlap an already-kept span. Sorts `ev`
67
- * in place and returns the kept count (compacted to the front of `ev`), in
68
- * ascending start order. n_total is the pattern-id upper bound for ordering.
64
+ * Resolve raw scan events into the final non-overlapping set under the
65
+ * longest-match-wins policy: process events in (start asc, length desc,
66
+ * pattern_id asc) order and keep an event iff its CORE span does not overlap an
67
+ * already-kept span. The longest match at each position wins; equal-length ties
68
+ * go to the lower pattern_id. Sorts `ev` in place and returns the kept count
69
+ * (compacted to the front of `ev`), in ascending start order.
69
70
  */
70
71
  size_t mm_resolve(mm_match_t *ev, size_t n);
71
72
 
@@ -425,18 +425,21 @@ const char *pattern_strings[NUM_PATTERNS] = {
425
425
  /* ---- Tier 2: Long prefixed tokens ---- */
426
426
  /* 6: GitHub PAT fine-grained (github_pat_ + 82 chars) */
427
427
  "github_pat_[0-9a-zA-Z_]{82}",
428
- /* 7: JWT (three base64url segments) */
429
- "eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+",
428
+ /* 7: JWT (three base64url segments). Tails bounded at RE_DUP_MAX (255):
429
+ * a JWT is unusable once its front is gone, so a bounded prefix is enough to
430
+ * neutralize it. Bounding restores a finite max_len (re-enables the engine's
431
+ * literal back-up skip) and removes the O(N^2) greedy-tail worst case. */
432
+ "eyJ[A-Za-z0-9_-]{10,255}\\.eyJ[A-Za-z0-9_-]{10,255}\\.[A-Za-z0-9_-]{1,255}",
430
433
  /* 8: Grafana API Token (base64 of {\"k\":\") */
431
- "eyJrIjoi[A-Za-z0-9_=-]{42,}",
434
+ "eyJrIjoi[A-Za-z0-9_=-]{42,255}",
432
435
  /* 9: SSH Public Key */
433
- "ssh-(rsa|ed25519|ecdsa) [a-zA-Z0-9/+=]{20,}",
436
+ "ssh-(rsa|ed25519|ecdsa) [a-zA-Z0-9/+=]{20,255}",
434
437
  /* 10: Bearer Token */
435
- "[Bb]earer [a-zA-Z0-9_.=/+:-]{12,}",
438
+ "[Bb]earer [a-zA-Z0-9_.=/+:-]{12,255}",
436
439
  /* 11: Anthropic API Key (sk-ant-apiNN-... ~ 95+ chars) */
437
- "sk-ant-api[0-9]{2}-[A-Za-z0-9_-]{90,}",
440
+ "sk-ant-api[0-9]{2}-[A-Za-z0-9_-]{90,255}",
438
441
  /* 12: OpenAI Project API Key (sk-proj-...) */
439
- "sk-proj-[A-Za-z0-9_-]{20,}",
442
+ "sk-proj-[A-Za-z0-9_-]{20,255}",
440
443
  /* 13: Google API Key (AIza + 35 chars) */
441
444
  "AIza[0-9A-Za-z_-]{35}",
442
445
  /* 14: AWS Access Key ID (all prefixes + 16 chars) */
@@ -444,7 +447,7 @@ const char *pattern_strings[NUM_PATTERNS] = {
444
447
  /* 15: AWS Secret Access Key (40 base64 chars) */
445
448
  "[A-Za-z0-9/+=]{40}",
446
449
  /* 16: SendGrid API Key */
447
- "SG\\.[a-zA-Z0-9_-]{5,}\\.[a-zA-Z0-9_-]{5,}",
450
+ "SG\\.[a-zA-Z0-9_-]{5,255}\\.[a-zA-Z0-9_-]{5,255}",
448
451
  /* 17: Amazon MWS Auth Token */
449
452
  "amzn\\.mws\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
450
453
  /* 18: LaunchDarkly API Key (api-UUID or sdk-UUID) */
@@ -20,6 +20,10 @@ size_t write_placeholder(char *buf, const placeholder_t *ph,
20
20
  unsigned int h = djb2(match, match_len) & 0xFFFF;
21
21
  return (size_t)sprintf(buf, "[%s_%04x]", ph->str, h);
22
22
  }
23
+ case PLACEHOLDER_MODE_LENGTH:
24
+ return (size_t)sprintf(buf, "[REDACTED:%zu]", match_len);
25
+ case PLACEHOLDER_MODE_TAGGED_LENGTH:
26
+ return (size_t)sprintf(buf, "[REDACTED:%s:%zu]", ph->str, match_len);
23
27
  default: /* PLACEHOLDER_MODE_PLAIN */
24
28
  {
25
29
  size_t len = strlen(ph->str);
@@ -29,11 +33,17 @@ size_t write_placeholder(char *buf, const placeholder_t *ph,
29
33
  }
30
34
  }
31
35
 
36
+ /* Widest decimal a size_t byte-length can print to (UINT64_MAX is 20 digits). */
37
+ #define MAX_LEN_DIGITS 20
38
+
32
39
  size_t max_placeholder_len(const placeholder_t *ph) {
33
40
  size_t tag_len = strlen(ph->str);
34
41
  switch (ph->mode) {
35
42
  case PLACEHOLDER_MODE_TAGGED: return 2 + 9 + tag_len + 1; /* "[REDACTED:" + tag + "]" */
36
43
  case PLACEHOLDER_MODE_HASH: return 1 + tag_len + 1 + 4 + 1; /* "[" + tag + "_" + 4hex + "]" */
44
+ case PLACEHOLDER_MODE_LENGTH: return 10 + MAX_LEN_DIGITS + 1; /* "[REDACTED:" + digits + "]" */
45
+ case PLACEHOLDER_MODE_TAGGED_LENGTH:
46
+ return 10 + tag_len + 1 + MAX_LEN_DIGITS + 1; /* "[REDACTED:" + tag + ":" + digits + "]" */
37
47
  default: return tag_len;
38
48
  }
39
49
  }
@@ -3,9 +3,11 @@
3
3
 
4
4
  #include <stddef.h>
5
5
 
6
- #define PLACEHOLDER_MODE_PLAIN 0 /* use ph.str verbatim */
7
- #define PLACEHOLDER_MODE_TAGGED 1 /* "[REDACTED:TAGNAME]" */
8
- #define PLACEHOLDER_MODE_HASH 2 /* "[TAGNAME_xxxx]" (4-hex djb2 suffix) */
6
+ #define PLACEHOLDER_MODE_PLAIN 0 /* use ph.str verbatim */
7
+ #define PLACEHOLDER_MODE_TAGGED 1 /* "[REDACTED:TAGNAME]" */
8
+ #define PLACEHOLDER_MODE_HASH 2 /* "[TAGNAME_xxxx]" (4-hex djb2 suffix) */
9
+ #define PLACEHOLDER_MODE_LENGTH 3 /* "[REDACTED:16]" (byte-length) */
10
+ #define PLACEHOLDER_MODE_TAGGED_LENGTH 4 /* "[REDACTED:TAGNAME:16]" */
9
11
 
10
12
  typedef struct {
11
13
  int mode;
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "data_redactor"
4
+
5
+ module DataRedactor
6
+ # Opt-in refinements that add a `#redact` method to `String`, `Hash`, and
7
+ # `Array` as sugar over {DataRedactor.redact} / {DataRedactor.redact_deep}.
8
+ #
9
+ # Refinements are lexically scoped: `#redact` exists only in files that
10
+ # `using DataRedactor::Refinements`, so loading this file never pollutes the
11
+ # core classes globally. Apps that don't opt in are unaffected, and there is
12
+ # no collision risk with other libraries' `String#redact`.
13
+ #
14
+ # `DataRedactor.redact` remains the primary API; this is convenience only.
15
+ #
16
+ # @example
17
+ # require "data_redactor/refinements"
18
+ # using DataRedactor::Refinements
19
+ #
20
+ # "email alice@example.com".redact #=> "email [REDACTED]"
21
+ # { token: "AKIAIOSFODNN7EXAMPLE" }.redact #=> { token: "[REDACTED]" }
22
+ # chat.ask(user_input.redact) # scrub before sending to an LLM
23
+ module Refinements
24
+ refine String do
25
+ # Redact this String via {DataRedactor.redact}. Returns a new String; the
26
+ # receiver is not mutated.
27
+ #
28
+ # @param only forwarded to {DataRedactor.redact}
29
+ # @param except forwarded to {DataRedactor.redact}
30
+ # @param placeholder forwarded to {DataRedactor.redact}
31
+ # @return [String] the redacted copy.
32
+ # @example
33
+ # "ssn 123-45-6789".redact #=> "ssn [REDACTED]"
34
+ def redact(only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
35
+ DataRedactor.redact(self, only: only, except: except, placeholder: placeholder)
36
+ end
37
+ end
38
+
39
+ refine Hash do
40
+ # Deep-redact this Hash's String values via {DataRedactor.redact_deep}.
41
+ # Returns a deep copy; the receiver is not mutated and keys are untouched.
42
+ #
43
+ # @param only forwarded to {DataRedactor.redact_deep}
44
+ # @param except forwarded to {DataRedactor.redact_deep}
45
+ # @param placeholder forwarded to {DataRedactor.redact_deep}
46
+ # @return [Hash] a deep copy with String leaves redacted.
47
+ # @example
48
+ # { email: "a@b.com" }.redact #=> { email: "[REDACTED]" }
49
+ def redact(only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
50
+ DataRedactor.redact_deep(self, only: only, except: except, placeholder: placeholder)
51
+ end
52
+ end
53
+
54
+ refine Array do
55
+ # Deep-redact this Array's String elements via {DataRedactor.redact_deep}.
56
+ # Returns a deep copy; the receiver is not mutated.
57
+ #
58
+ # @param only forwarded to {DataRedactor.redact_deep}
59
+ # @param except forwarded to {DataRedactor.redact_deep}
60
+ # @param placeholder forwarded to {DataRedactor.redact_deep}
61
+ # @return [Array] a deep copy with String leaves redacted.
62
+ # @example
63
+ # ["a@b.com", 3].redact #=> ["[REDACTED]", 3]
64
+ def redact(only: nil, except: nil, placeholder: DataRedactor::PLACEHOLDER_DEFAULT)
65
+ DataRedactor.redact_deep(self, only: only, except: except, placeholder: placeholder)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,4 +1,4 @@
1
1
  module DataRedactor
2
2
  # Current gem version. Follows {https://semver.org Semantic Versioning 2.0.0}.
3
- VERSION = "0.14.0"
3
+ VERSION = "0.16.0"
4
4
  end
data/lib/data_redactor.rb CHANGED
@@ -23,8 +23,10 @@ require_relative "data_redactor/name_pattern"
23
23
  #
24
24
  # @example Custom placeholder
25
25
  # DataRedactor.redact(text, placeholder: "***")
26
- # DataRedactor.redact(text, placeholder: :tagged) # => "[REDACTED:CONTACT]"
27
- # DataRedactor.redact(text, placeholder: :hash) # => "[CONTACT_a3f9]"
26
+ # DataRedactor.redact(text, placeholder: :tagged) # => "[REDACTED:CONTACT]"
27
+ # DataRedactor.redact(text, placeholder: :hash) # => "[CONTACT_a3f9]"
28
+ # DataRedactor.redact(text, placeholder: :length) # => "[REDACTED:16]"
29
+ # DataRedactor.redact(text, placeholder: :tagged_length) # => "[REDACTED:CONTACT:16]"
28
30
  #
29
31
  # @example Audit / dry-run
30
32
  # DataRedactor.scan(text)
@@ -125,12 +127,15 @@ module DataRedactor
125
127
  # and/or pattern name(s).
126
128
  # @param except [Symbol, String, Array, nil] exclude the given tag(s)
127
129
  # and/or pattern name(s). May be combined with +only:+.
128
- # @param placeholder [String, :tagged, :hash] replacement strategy.
129
- # A String is used verbatim. +:tagged+ produces +[REDACTED:TAGNAME]+.
130
- # +:hash+ produces a deterministic +[TAGNAME_xxxx]+ token (4-hex djb2)
131
- # so the same input value always maps to the same token.
130
+ # @param placeholder [String, :tagged, :hash, :length, :tagged_length]
131
+ # replacement strategy. A String is used verbatim. +:tagged+ produces
132
+ # +[REDACTED:TAGNAME]+. +:hash+ produces a deterministic +[TAGNAME_xxxx]+
133
+ # token (4-hex djb2) so the same input value always maps to the same token.
134
+ # +:length+ produces +[REDACTED:N]+ and +:tagged_length+ produces
135
+ # +[REDACTED:TAGNAME:N]+, where +N+ is the byte length of the redacted value.
132
136
  # @return [String] a new string with every match replaced.
133
- # @raise [ArgumentError] if +placeholder:+ is not a String/:tagged/:hash.
137
+ # @raise [ArgumentError] if +placeholder:+ is not a String/:tagged/:hash/
138
+ # :length/:tagged_length.
134
139
  # @raise [UnknownTagError] if any Symbol in +only:+/+except:+ is not in {TAGS}.
135
140
  # @raise [UnknownPatternError] if any String in +only:+/+except:+ is not in {pattern_names}.
136
141
  #
@@ -194,7 +199,7 @@ module DataRedactor
194
199
  # Any type is accepted; non-String scalars are returned as-is.
195
200
  # @param only [Symbol, String, Array, nil] forwarded to {redact}.
196
201
  # @param except [Symbol, String, Array, nil] forwarded to {redact}.
197
- # @param placeholder [String, :tagged, :hash] forwarded to {redact}.
202
+ # @param placeholder [String, :tagged, :hash, :length, :tagged_length] forwarded to {redact}.
198
203
  # @return [Hash, Array, String, Object] a new structure of the same shape
199
204
  # with all String leaves redacted.
200
205
  # @raise [ArgumentError] if the structure contains a circular reference.
@@ -217,7 +222,7 @@ module DataRedactor
217
222
  # @param json_string [String] valid JSON input.
218
223
  # @param only [Symbol, String, Array, nil] forwarded to {redact}.
219
224
  # @param except [Symbol, String, Array, nil] forwarded to {redact}.
220
- # @param placeholder [String, :tagged, :hash] forwarded to {redact}.
225
+ # @param placeholder [String, :tagged, :hash, :length, :tagged_length] forwarded to {redact}.
221
226
  # @return [String] a JSON string with all String values redacted.
222
227
  # @raise [JSON::ParserError] if +json_string+ is not valid JSON.
223
228
  #
@@ -425,17 +430,20 @@ module DataRedactor
425
430
  # Translate the user-facing +placeholder:+ value into the +(mode_int, str)+
426
431
  # pair the C layer expects.
427
432
  #
428
- # @param placeholder [String, :tagged, :hash]
433
+ # @param placeholder [String, :tagged, :hash, :length, :tagged_length]
429
434
  # @return [Array(Integer, String)]
430
435
  # @raise [ArgumentError] if +placeholder+ is none of the accepted values.
431
436
  def resolve_placeholder(placeholder)
432
437
  case placeholder
433
- when :tagged then [PH_MODE_TAGGED, ""]
434
- when :hash then [PH_MODE_HASH, ""]
435
- when String then [PH_MODE_PLAIN, placeholder]
438
+ when :tagged then [PH_MODE_TAGGED, ""]
439
+ when :hash then [PH_MODE_HASH, ""]
440
+ when :length then [PH_MODE_LENGTH, ""]
441
+ when :tagged_length then [PH_MODE_TAGGED_LENGTH, ""]
442
+ when String then [PH_MODE_PLAIN, placeholder]
436
443
  else
437
444
  raise ArgumentError,
438
- "placeholder must be a String, :tagged, or :hash got #{placeholder.inspect}"
445
+ "placeholder must be a String, :tagged, :hash, :length, or :tagged_length " \
446
+ "— got #{placeholder.inspect}"
439
447
  end
440
448
  end
441
449
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: data_redactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniele Frisanco
@@ -145,6 +145,7 @@ files:
145
145
  - lib/data_redactor/integrations/rack.rb
146
146
  - lib/data_redactor/integrations/rails.rb
147
147
  - lib/data_redactor/name_pattern.rb
148
+ - lib/data_redactor/refinements.rb
148
149
  - lib/data_redactor/version.rb
149
150
  homepage: https://github.com/danielefrisanco/data_redactor
150
151
  licenses: