data_redactor 0.8.0-x86_64-linux → 0.10.0-x86_64-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 +4 -4
- data/CHANGELOG.md +37 -1
- data/lib/data_redactor/3.0/data_redactor.so +0 -0
- data/lib/data_redactor/3.1/data_redactor.so +0 -0
- data/lib/data_redactor/3.2/data_redactor.so +0 -0
- data/lib/data_redactor/3.3/data_redactor.so +0 -0
- data/lib/data_redactor/3.4/data_redactor.so +0 -0
- data/lib/data_redactor/4.0/data_redactor.so +0 -0
- data/lib/data_redactor/integrations/rack.rb +21 -0
- data/lib/data_redactor/name_pattern.rb +170 -0
- data/lib/data_redactor/version.rb +1 -1
- data/lib/data_redactor.rb +76 -1
- data/readme.md +122 -4
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c68f704fce41662e1b7829085e9fadffa4931dae4323f528916897164cfdf5b
|
|
4
|
+
data.tar.gz: 1d91d3f55059e579cf36967eb06294ed18e7a54c3c1396971a749d30570118e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5df30474632d5026ca2a79e8be61fc020a4cb72cd7c3fb9333134d03a7d8430380185a0d209eabab33b2f27b6502ca7e9e34fb5c8fdd6df9ac24b2d71e33bd5
|
|
7
|
+
data.tar.gz: b0fd12a2666dfa50e9dd18ecb3a6733ca712ea5f1a8ea31305bd0cf788fa766cba9720c3891deec67c3eace73dafca2cc5fc566712d8bdb627b2bc9eac818b81
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-06-09
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Engine rewrite (v19 hybrid)** — `redact` and `scan` now run through a
|
|
14
|
+
Thompson NFA → bytecode → lazy-DFA multi-pattern engine (v19) for all 88
|
|
15
|
+
built-in patterns, replacing the previous per-pattern POSIX `regexec` loop.
|
|
16
|
+
Custom patterns (`add_pattern`) continue to use the glibc path (hybrid split
|
|
17
|
+
— required for correct UTF-8 multibyte character-class matching in user regex).
|
|
18
|
+
- Throughput on a 1 MB log: **~8.4× faster** than the previous C engine
|
|
19
|
+
(0.87 i/s → 7.27 i/s); **2.25× faster** than pure-Ruby `gsub` (was 4×
|
|
20
|
+
slower). Small per-call strings: 1.7–2.3× faster (was 3–4.6× slower).
|
|
21
|
+
- Overlap resolution: built-in matches are now resolved by an index-order
|
|
22
|
+
greedy claim (`mm_resolve`) that reproduces today's sequential per-pattern
|
|
23
|
+
rewrite semantics exactly. The one accepted divergence (rewrite-created
|
|
24
|
+
boundary when two secrets abut with no separator) is documented in
|
|
25
|
+
`TODO.md §1d` and pinned by `DIVERGENCE` specs.
|
|
26
|
+
- `rb_data_redactor_scan`: coordinate mapping (`repl_log` / `WORKING_TO_ORIG`)
|
|
27
|
+
replaced by direct original-frame offset emission from the v19 engine; custom
|
|
28
|
+
patterns use a lightweight offset-walk over the built-in event list.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **Swiss AHV false-negative** — boundary-wrapped patterns with a
|
|
32
|
+
start-anchored required literal now correctly set `max_back = 1` (not 0) so
|
|
33
|
+
the literal-skip does not overshoot the boundary byte. `756.1234.5678.90`
|
|
34
|
+
now matches as expected. (Pre-existing bug in the old engine, caught by
|
|
35
|
+
going live.)
|
|
36
|
+
|
|
37
|
+
## [0.9.0] - 2026-05-22
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- `DataRedactor.name_pattern(first, last, middle:)` — generates a POSIX ERE that matches a person's name across common written variations (case-insensitivity, First/Last order swaps, `Last, First`, initials, diacritics, and interchangeable space/hyphen separators). Returns a String ready to pass to `add_pattern`. The pattern is boundary-wrapped, so `"Mario"` matches as a word but not inside `"Mariolino"`. When `middle:` is given, both the no-middle and with-middle forms match.
|
|
41
|
+
|
|
42
|
+
## [0.8.0] - 2026-05-21
|
|
43
|
+
|
|
10
44
|
### Added
|
|
11
45
|
- `DataRedactor.redact_deep(data, only:, except:, placeholder:)` — recursively redacts every String value in a nested Hash/Array structure. Non-string scalars (Integer, Float, nil, Boolean) and Hash keys are passed through unchanged. Returns a deep copy; never mutates the input. Raises `ArgumentError` on circular references.
|
|
12
46
|
- `DataRedactor.redact_json(json_string, only:, except:, placeholder:)` — parses JSON, redacts via `redact_deep`, and returns valid JSON. Raises `JSON::ParserError` on invalid input.
|
|
@@ -170,7 +204,9 @@ features as 0.7.1 plus the pipeline fix.
|
|
|
170
204
|
- `DataRedactor.redact(text)` module function returning the input with every match replaced by `[REDACTED]`.
|
|
171
205
|
- RSpec suite with one example per pattern.
|
|
172
206
|
|
|
173
|
-
[Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.
|
|
207
|
+
[Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.9.0...HEAD
|
|
208
|
+
[0.9.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.8.0...v0.9.0
|
|
209
|
+
[0.8.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.2...v0.8.0
|
|
174
210
|
[0.7.2]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.1...v0.7.2
|
|
175
211
|
[0.7.1]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.0...v0.7.1
|
|
176
212
|
[0.7.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.6.1...v0.7.0
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
require "data_redactor"
|
|
2
2
|
|
|
3
3
|
module DataRedactor
|
|
4
|
+
# Namespace for the optional framework adapters under
|
|
5
|
+
# +lib/data_redactor/integrations/+ ({Logger}, +Rails+, {Rack}).
|
|
6
|
+
#
|
|
7
|
+
# Each adapter is soft-required — none load with +require "data_redactor"+;
|
|
8
|
+
# +require+ only the one you need. They add no runtime gem dependencies and
|
|
9
|
+
# all redaction is delegated to {DataRedactor.redact}.
|
|
4
10
|
module Integrations
|
|
5
11
|
# Rack middleware that scrubs sensitive data from selectable surfaces of
|
|
6
12
|
# the response (and request headers, for downstream loggers to see scrubbed
|
|
@@ -23,8 +29,13 @@ module DataRedactor
|
|
|
23
29
|
# the env hash so any downstream middleware that logs them sees scrubbed
|
|
24
30
|
# values.
|
|
25
31
|
class Rack
|
|
32
|
+
# Surfaces scrubbed when +scrub:+ is not given to {#initialize}.
|
|
33
|
+
# @return [Array<Symbol>]
|
|
26
34
|
DEFAULT_SCRUB = [:body, :headers].freeze
|
|
27
35
|
|
|
36
|
+
# Request-header env keys redacted in place when +:headers+ is scrubbed,
|
|
37
|
+
# so downstream middleware that logs the env sees scrubbed values.
|
|
38
|
+
# @return [Array<String>] Rack env keys (HTTP_-prefixed, upper-case).
|
|
28
39
|
SENSITIVE_REQUEST_HEADERS = %w[
|
|
29
40
|
HTTP_AUTHORIZATION
|
|
30
41
|
HTTP_PROXY_AUTHORIZATION
|
|
@@ -34,6 +45,9 @@ module DataRedactor
|
|
|
34
45
|
HTTP_X_ACCESS_TOKEN
|
|
35
46
|
].freeze
|
|
36
47
|
|
|
48
|
+
# Response headers whose values are redacted when +:headers+ is scrubbed.
|
|
49
|
+
# Matched case-insensitively (Rack 2 capitalises, Rack 3 lower-cases).
|
|
50
|
+
# @return [Array<String>]
|
|
37
51
|
SENSITIVE_RESPONSE_HEADERS = %w[
|
|
38
52
|
Set-Cookie
|
|
39
53
|
Authorization
|
|
@@ -60,6 +74,13 @@ module DataRedactor
|
|
|
60
74
|
@placeholder = placeholder
|
|
61
75
|
end
|
|
62
76
|
|
|
77
|
+
# Rack entry point. Scrubs the configured surfaces of the request and
|
|
78
|
+
# response and returns the standard Rack response triple.
|
|
79
|
+
#
|
|
80
|
+
# @param env [Hash] the Rack environment.
|
|
81
|
+
# @return [Array(Integer, Hash, #each)] the +[status, headers, body]+
|
|
82
|
+
# triple, with sensitive data redacted from the surfaces named in
|
|
83
|
+
# +scrub:+. When +:body+ is scrubbed, +Content-Length+ is dropped.
|
|
63
84
|
def call(env)
|
|
64
85
|
scrub_request_headers(env) if @scrub.include?(:headers)
|
|
65
86
|
status, headers, body = @app.call(env)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataRedactor
|
|
4
|
+
# Maps a base ASCII letter to the set of accented characters that should
|
|
5
|
+
# also match it. Used to make generated name patterns diacritic-tolerant:
|
|
6
|
+
# an input "Jose" still matches "José", and "Munoz" matches "Muñoz".
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
DIACRITIC_FOLD = {
|
|
10
|
+
"a" => "àáâãäåāăą",
|
|
11
|
+
"c" => "çćĉċč",
|
|
12
|
+
"e" => "èéêëēĕėęě",
|
|
13
|
+
"i" => "ìíîïĩīĭįı",
|
|
14
|
+
"n" => "ñńņňʼn",
|
|
15
|
+
"o" => "òóôõöøōŏő",
|
|
16
|
+
"u" => "ùúûüũūŭůűų",
|
|
17
|
+
"y" => "ýÿŷ",
|
|
18
|
+
"s" => "śŝşš",
|
|
19
|
+
"z" => "źżž",
|
|
20
|
+
"g" => "ĝğġģ",
|
|
21
|
+
"l" => "ĺļľŀł",
|
|
22
|
+
"r" => "ŕŗř",
|
|
23
|
+
"t" => "ţťŧ"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Build a POSIX ERE that matches a person's name across common written
|
|
29
|
+
# variations, ready to hand to {add_pattern}.
|
|
30
|
+
#
|
|
31
|
+
# The returned pattern is **boundary-wrapped** — it embeds
|
|
32
|
+
# +(^|[^A-Za-z])+ ... +([^A-Za-z]|$)+ so that +"Mario"+ matches as a whole
|
|
33
|
+
# word but not inside +"Mariolino"+. Because the wrapper uses capture
|
|
34
|
+
# groups, register the pattern with the default +boundary: false+ (do
|
|
35
|
+
# **not** pass +boundary: true+ — that would double-wrap and reject the
|
|
36
|
+
# groups).
|
|
37
|
+
#
|
|
38
|
+
# Variations covered:
|
|
39
|
+
# - **Case** — every letter becomes a case-insensitive character class
|
|
40
|
+
# (+[Mm][Aa]...+), since POSIX ERE has no +/i+ flag.
|
|
41
|
+
# - **Order** — +"First Last"+, +"Last First"+, +"Last, First"+,
|
|
42
|
+
# +"Last,First"+.
|
|
43
|
+
# - **Initials** — +"M. Last"+, +"M Last"+, +"First R."+, +"First R"+,
|
|
44
|
+
# +"M.R."+, +"M R"+, +"MR"+.
|
|
45
|
+
# - **Diacritics** — an ASCII letter with a {DIACRITIC_FOLD} entry also
|
|
46
|
+
# matches its accented forms (+"Jose"+ matches +"José"+). An accented
|
|
47
|
+
# input letter also matches its bare ASCII form.
|
|
48
|
+
# - **Separators** — spaces and hyphens are interchangeable between and
|
|
49
|
+
# within name parts. A hyphenated part like +"Anne-Marie"+ also matches
|
|
50
|
+
# +"Anne Marie"+, +"AnneMarie"+, and each half on its own (+"Anne"+,
|
|
51
|
+
# +"Marie"+). Multi-word parts like +"Van der Berg"+ tolerate any
|
|
52
|
+
# space/hyphen separator between words.
|
|
53
|
+
#
|
|
54
|
+
# @param first [String] the given name. May contain hyphens or spaces.
|
|
55
|
+
# @param last [String] the family name. May contain hyphens or spaces.
|
|
56
|
+
# @param middle [String, nil] optional middle name. When given, the pattern
|
|
57
|
+
# matches **both** the no-middle forms and the with-middle forms.
|
|
58
|
+
# @return [String] a POSIX ERE source string.
|
|
59
|
+
# @raise [ArgumentError] if +first+ or +last+ is not a non-empty String,
|
|
60
|
+
# or +middle+ is given but is not a non-empty String.
|
|
61
|
+
#
|
|
62
|
+
# @example Register a name pattern
|
|
63
|
+
# DataRedactor.add_pattern(
|
|
64
|
+
# name: "person_mario_rossi",
|
|
65
|
+
# regex: DataRedactor.name_pattern("Mario", "Rossi"),
|
|
66
|
+
# tag: :contact
|
|
67
|
+
# )
|
|
68
|
+
#
|
|
69
|
+
# @example With a middle name
|
|
70
|
+
# DataRedactor.name_pattern("Mario", "Rossi", middle: "Luigi")
|
|
71
|
+
def name_pattern(first, last, middle: nil)
|
|
72
|
+
_validate_name_arg!(first, "first")
|
|
73
|
+
_validate_name_arg!(last, "last")
|
|
74
|
+
_validate_name_arg!(middle, "middle") unless middle.nil?
|
|
75
|
+
|
|
76
|
+
first_tok = _part_token(first)
|
|
77
|
+
last_tok = _part_token(last)
|
|
78
|
+
middle_tok = middle && _part_token(middle)
|
|
79
|
+
|
|
80
|
+
# Separator between name parts. Optional so initial-only forms collapse
|
|
81
|
+
# ("MR", "M.R.") and so "First,Last" with no space still matches.
|
|
82
|
+
sep = "[ ,-]*"
|
|
83
|
+
|
|
84
|
+
bodies = []
|
|
85
|
+
bodies << "#{first_tok}#{sep}#{last_tok}" # First Last
|
|
86
|
+
bodies << "#{last_tok}#{sep}#{first_tok}" # Last First / Last, First
|
|
87
|
+
|
|
88
|
+
if middle_tok
|
|
89
|
+
bodies << "#{first_tok}#{sep}#{middle_tok}#{sep}#{last_tok}" # First Middle Last
|
|
90
|
+
bodies << "#{last_tok}#{sep}#{first_tok}#{sep}#{middle_tok}" # Last First Middle
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
"(^|[^A-Za-z])(#{bodies.join('|')})([^A-Za-z]|$)"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @api private
|
|
97
|
+
# Build the alternation for one name part: the full case-insensitive name,
|
|
98
|
+
# or its initial (with optional dot). Hyphenated/multi-word parts also
|
|
99
|
+
# match each sub-word alone and tolerant separators between sub-words.
|
|
100
|
+
#
|
|
101
|
+
# @param part [String] a single name part, e.g. "Mario" or "Anne-Marie".
|
|
102
|
+
# @return [String] a parenthesised POSIX ERE alternation.
|
|
103
|
+
def _part_token(part)
|
|
104
|
+
words = part.split(/[ -]+/).reject(&:empty?)
|
|
105
|
+
|
|
106
|
+
word_alts = words.map { |w| _word_alternatives(w) }
|
|
107
|
+
|
|
108
|
+
forms = []
|
|
109
|
+
# whole part with tolerant separators between its words
|
|
110
|
+
forms << word_alts.map { |alts| "(#{alts.join('|')})" }.join("[ -]?")
|
|
111
|
+
# each word on its own (covers "Anne" / "Marie" from "Anne-Marie")
|
|
112
|
+
if words.length > 1
|
|
113
|
+
word_alts.each { |alts| forms << "(#{alts.join('|')})" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
"(#{forms.uniq.join('|')})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
# Alternatives for a single whitespace-free word: the full name (each
|
|
121
|
+
# letter as a case-insensitive, diacritic-folded class) and its initial.
|
|
122
|
+
#
|
|
123
|
+
# @param word [String] a single word with no spaces or hyphens.
|
|
124
|
+
# @return [Array<String>] alternation members for this word.
|
|
125
|
+
def _word_alternatives(word)
|
|
126
|
+
full = word.chars.map { |ch| _letter_class(ch) }.join
|
|
127
|
+
initial = "#{_letter_class(word[0])}\\.?"
|
|
128
|
+
[full, initial]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @api private
|
|
132
|
+
# Build a POSIX bracket expression matching one letter case-insensitively
|
|
133
|
+
# and, where applicable, its accented variants.
|
|
134
|
+
#
|
|
135
|
+
# @param char [String] a single character.
|
|
136
|
+
# @return [String] a bracket expression, e.g. "[Mm]" or "[EeÈÉÊËèéêë]".
|
|
137
|
+
def _letter_class(char)
|
|
138
|
+
down = char.downcase
|
|
139
|
+
up = char.upcase
|
|
140
|
+
members = [down]
|
|
141
|
+
members << up unless up == down
|
|
142
|
+
|
|
143
|
+
base = DIACRITIC_FOLD.key?(down) ? down : _ascii_base(down)
|
|
144
|
+
if base && DIACRITIC_FOLD.key?(base)
|
|
145
|
+
accented = DIACRITIC_FOLD[base]
|
|
146
|
+
members << accented << accented.upcase
|
|
147
|
+
members << base << base.upcase # accented input still matches bare ASCII
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
"[#{members.join}]"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @api private
|
|
154
|
+
# If +char+ is an accented letter, return the bare ASCII letter it folds
|
|
155
|
+
# to; otherwise nil.
|
|
156
|
+
#
|
|
157
|
+
# @param char [String] a single lowercase character.
|
|
158
|
+
# @return [String, nil]
|
|
159
|
+
def _ascii_base(char)
|
|
160
|
+
DIACRITIC_FOLD.each { |ascii, accents| return ascii if accents.include?(char) }
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @api private
|
|
165
|
+
def _validate_name_arg!(value, label)
|
|
166
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
167
|
+
|
|
168
|
+
raise ArgumentError, "#{label} must be a non-empty String, got #{value.inspect}"
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/data_redactor.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "set"
|
|
|
2
2
|
require "json"
|
|
3
3
|
require_relative "data_redactor/version"
|
|
4
4
|
require_relative "data_redactor/data_redactor" # loads the compiled .so
|
|
5
|
+
require_relative "data_redactor/name_pattern"
|
|
5
6
|
|
|
6
7
|
# High-performance regex-based redactor for sensitive data.
|
|
7
8
|
#
|
|
@@ -73,6 +74,15 @@ module DataRedactor
|
|
|
73
74
|
# Default placeholder used when +placeholder:+ is not given to {redact}.
|
|
74
75
|
PLACEHOLDER_DEFAULT = "[REDACTED]"
|
|
75
76
|
|
|
77
|
+
# @api private
|
|
78
|
+
# Inputs larger than this (bytes) are split into newline-bounded chunks before
|
|
79
|
+
# being handed to the C engine. Bounds the per-call O(N) cost glibc regexec
|
|
80
|
+
# pays for state-log allocation, turning total redaction cost from O(N²) (one
|
|
81
|
+
# giant pass) into O(N × CHUNK_SIZE) (many bounded passes). 64 KB is a
|
|
82
|
+
# compromise: small enough to keep per-call cost low, large enough that
|
|
83
|
+
# typical log/JSON inputs use few chunks. See option G in TODO.md.
|
|
84
|
+
CHUNK_SIZE = 64 * 1024
|
|
85
|
+
|
|
76
86
|
module_function
|
|
77
87
|
|
|
78
88
|
# List of supported tag symbols.
|
|
@@ -131,6 +141,11 @@ module DataRedactor
|
|
|
131
141
|
def redact(text, only: nil, except: nil, placeholder: PLACEHOLDER_DEFAULT)
|
|
132
142
|
enable_bits = build_enable_bits(only, except)
|
|
133
143
|
ph_mode, ph_str = resolve_placeholder(placeholder)
|
|
144
|
+
# Defer to the C layer's TypeError for non-Strings; only chunk if the input
|
|
145
|
+
# is a String big enough to benefit (avoid bytesize on non-Strings).
|
|
146
|
+
if text.is_a?(String) && text.bytesize > CHUNK_SIZE
|
|
147
|
+
return _chunk_bytes(text).map { |c| _redact(c, ph_mode, ph_str, enable_bits) }.join
|
|
148
|
+
end
|
|
134
149
|
_redact(text, ph_mode, ph_str, enable_bits)
|
|
135
150
|
end
|
|
136
151
|
|
|
@@ -156,7 +171,12 @@ module DataRedactor
|
|
|
156
171
|
# # value: "user@example.com", start: 0, length: 16}] }
|
|
157
172
|
def scan(text, only: nil, except: nil)
|
|
158
173
|
enable_bits = build_enable_bits(only, except)
|
|
159
|
-
result =
|
|
174
|
+
result =
|
|
175
|
+
if text.is_a?(String) && text.bytesize > CHUNK_SIZE
|
|
176
|
+
_chunked_scan(text, enable_bits)
|
|
177
|
+
else
|
|
178
|
+
_scan(text, enable_bits)
|
|
179
|
+
end
|
|
160
180
|
# Normalise: convert tag string from C (uppercase) back to the Symbol used in TAGS
|
|
161
181
|
result[:matches].each { |m| m[:tag] = m[:tag].to_s.downcase.to_sym }
|
|
162
182
|
result
|
|
@@ -418,4 +438,59 @@ module DataRedactor
|
|
|
418
438
|
"placeholder must be a String, :tagged, or :hash — got #{placeholder.inspect}"
|
|
419
439
|
end
|
|
420
440
|
end
|
|
441
|
+
|
|
442
|
+
# @api private
|
|
443
|
+
# Split +text+ into byte-bounded chunks for the chunked redact/scan path.
|
|
444
|
+
# Chunks end at a +\n+ when possible so no match straddles a boundary; if a
|
|
445
|
+
# single line exceeds {CHUNK_SIZE} (rare in real inputs), it becomes one
|
|
446
|
+
# oversized chunk and pays the per-pattern O(N) cost — documented limitation.
|
|
447
|
+
# Returns an Array of byte-Strings whose concatenation equals +text+ exactly
|
|
448
|
+
# (including the original newline separators).
|
|
449
|
+
#
|
|
450
|
+
# @param text [String]
|
|
451
|
+
# @return [Array<String>]
|
|
452
|
+
def _chunk_bytes(text)
|
|
453
|
+
chunks = []
|
|
454
|
+
pos = 0
|
|
455
|
+
len = text.bytesize
|
|
456
|
+
while pos < len
|
|
457
|
+
remaining = len - pos
|
|
458
|
+
if remaining <= CHUNK_SIZE
|
|
459
|
+
chunks << text.byteslice(pos, remaining)
|
|
460
|
+
break
|
|
461
|
+
end
|
|
462
|
+
# Find the last \n in [pos, pos+CHUNK_SIZE). If none, chunk is one long
|
|
463
|
+
# line — take CHUNK_SIZE bytes as a fallback (boundary-split risk).
|
|
464
|
+
window = text.byteslice(pos, CHUNK_SIZE)
|
|
465
|
+
nl = window.rindex("\n")
|
|
466
|
+
take = nl ? nl + 1 : CHUNK_SIZE
|
|
467
|
+
chunks << text.byteslice(pos, take)
|
|
468
|
+
pos += take
|
|
469
|
+
end
|
|
470
|
+
chunks
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# @api private
|
|
474
|
+
# Chunked variant of +_scan+: runs the C scanner on each chunk, then offsets
|
|
475
|
+
# each match's +:start+ by the chunk's base byte-position in the original
|
|
476
|
+
# input so the byteslice invariant holds end-to-end.
|
|
477
|
+
#
|
|
478
|
+
# @param text [String]
|
|
479
|
+
# @param enable_bits [Array<Integer>]
|
|
480
|
+
# @return [Hash{Symbol => Object}] +{ redacted: String, matches: Array<Hash> }+
|
|
481
|
+
def _chunked_scan(text, enable_bits)
|
|
482
|
+
redacted = +""
|
|
483
|
+
matches = []
|
|
484
|
+
base = 0
|
|
485
|
+
_chunk_bytes(text).each do |chunk|
|
|
486
|
+
part = _scan(chunk, enable_bits)
|
|
487
|
+
redacted << part[:redacted]
|
|
488
|
+
part[:matches].each do |m|
|
|
489
|
+
m[:start] += base
|
|
490
|
+
matches << m
|
|
491
|
+
end
|
|
492
|
+
base += chunk.bytesize
|
|
493
|
+
end
|
|
494
|
+
{ redacted: redacted, matches: matches }
|
|
495
|
+
end
|
|
421
496
|
end
|
data/readme.md
CHANGED
|
@@ -8,7 +8,33 @@ A Ruby gem with a C extension for high-performance regex-based redaction of sens
|
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
DataRedactor scans text for sensitive
|
|
11
|
+
DataRedactor scans text for sensitive data — API keys and cloud secrets, IBANs,
|
|
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 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.
|
|
17
|
+
|
|
18
|
+
It ships **88 built-in patterns** across 15+ countries, grouped into tags
|
|
19
|
+
(`:credentials`, `:financial`, `:contact`, ...) so you can redact only what you
|
|
20
|
+
care about. Beyond plain strings it can walk nested Hashes, Arrays, and JSON,
|
|
21
|
+
audit a payload without mutating it (`scan`), and plug into Logger, Rails, and
|
|
22
|
+
Rack. You can also register your own patterns at boot.
|
|
23
|
+
|
|
24
|
+
### Use cases
|
|
25
|
+
|
|
26
|
+
- **Log scrubbing** — drop the `Logger` formatter in so no secret or PII ever
|
|
27
|
+
reaches disk or your log aggregator.
|
|
28
|
+
- **Rails parameter filtering** — feed `filter_parameters` a redactor-backed proc
|
|
29
|
+
to keep request params out of logs and error reports.
|
|
30
|
+
- **HTTP request/response sanitising** — Rack middleware scrubs response bodies
|
|
31
|
+
and sensitive headers in flight.
|
|
32
|
+
- **Sanitising LLM / API payloads** — run `redact_deep` over a params hash or
|
|
33
|
+
`redact_json` over a JSON body before it leaves the process.
|
|
34
|
+
- **Compliance & auditing** — `scan` reports every match with byte offsets, tag,
|
|
35
|
+
and pattern name without changing the text, for false-positive tuning.
|
|
36
|
+
- **Internal identifiers** — register company-specific patterns (`add_pattern`)
|
|
37
|
+
or generate them from a person's name (`name_pattern`).
|
|
12
38
|
|
|
13
39
|
## Usage
|
|
14
40
|
|
|
@@ -158,6 +184,46 @@ DataRedactor.clear_custom_patterns! # mostly for test suites
|
|
|
158
184
|
|
|
159
185
|
**`boundary: true`** — wraps the pattern with `(^|[^0-9A-Za-z])(PATTERN)([^0-9A-Za-z]|$)` so it only fires when the token is not embedded in a longer alphanumeric string. Incompatible with patterns that contain capture groups.
|
|
160
186
|
|
|
187
|
+
### Name patterns
|
|
188
|
+
|
|
189
|
+
Personal names can't ship as built-ins — every team has different ones — but the regex
|
|
190
|
+
boilerplate to match a name across its written variations is the same every time.
|
|
191
|
+
`name_pattern` generates that regex for you, ready to hand to `add_pattern`:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
DataRedactor.add_pattern(
|
|
195
|
+
name: "person_mario_rossi",
|
|
196
|
+
regex: DataRedactor.name_pattern("Mario", "Rossi"),
|
|
197
|
+
tag: :contact
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
DataRedactor.redact("ticket from Mario Rossi about ...")
|
|
201
|
+
# => "ticket from [REDACTED] about ..."
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A single generated pattern matches all of these:
|
|
205
|
+
|
|
206
|
+
- **Case** — `Mario Rossi`, `mario rossi`, `MARIO ROSSI`
|
|
207
|
+
- **Order** — `Mario Rossi`, `Rossi Mario`, `Rossi, Mario`, `Rossi,Mario`
|
|
208
|
+
- **Initials** — `M. Rossi`, `M Rossi`, `Mario R.`, `M.R.`, `MR`
|
|
209
|
+
- **Diacritics** — `name_pattern("Jose", "Munoz")` also matches `José Muñoz` (and vice versa)
|
|
210
|
+
- **Separators** — spaces and hyphens are interchangeable. `name_pattern("Anne-Marie", "Berg")`
|
|
211
|
+
matches `Anne-Marie Berg`, `Anne Marie Berg`, `AnneMarie Berg`, and each half alone
|
|
212
|
+
(`Anne Berg`, `Marie Berg`). Multi-word parts like `"Van der Berg"` tolerate any
|
|
213
|
+
space/hyphen separator between words.
|
|
214
|
+
|
|
215
|
+
It does **not** match a name embedded in a longer word — `Mario` will not fire inside
|
|
216
|
+
`Mariolino` — because the generated pattern is boundary-wrapped. For that reason, register
|
|
217
|
+
it with the default `boundary: false` (the wrapper is already baked into the returned
|
|
218
|
+
string; `boundary: true` would double-wrap and reject its capture groups).
|
|
219
|
+
|
|
220
|
+
Pass `middle:` to also cover a middle name — both the no-middle and with-middle forms match:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
DataRedactor.name_pattern("Mario", "Rossi", middle: "Luigi")
|
|
224
|
+
# matches "Mario Rossi" AND "Mario Luigi Rossi" AND "Rossi Mario Luigi"
|
|
225
|
+
```
|
|
226
|
+
|
|
161
227
|
## Integrations
|
|
162
228
|
|
|
163
229
|
Optional adapters for Logger, Rails, and Rack. None are loaded automatically — `require` only what you use, and the gem adds zero runtime dependencies in the gemspec.
|
|
@@ -306,7 +372,9 @@ redactor/
|
|
|
306
372
|
├── lib/
|
|
307
373
|
│ ├── data_redactor.rb # Ruby entry point, loads the .so
|
|
308
374
|
│ └── data_redactor/
|
|
309
|
-
│
|
|
375
|
+
│ ├── version.rb
|
|
376
|
+
│ ├── name_pattern.rb # name_pattern helper — generates a name regex for add_pattern
|
|
377
|
+
│ └── integrations/ # soft-required Logger / Rails / Rack adapters
|
|
310
378
|
├── ext/
|
|
311
379
|
│ └── data_redactor/
|
|
312
380
|
│ ├── extconf.rb # Checks for C headers, generates Makefile (globs *.c)
|
|
@@ -317,8 +385,18 @@ redactor/
|
|
|
317
385
|
│ ├── scan.{c,h} # _scan + byte-offset replacement-log macros
|
|
318
386
|
│ ├── custom_patterns.{c,h} # Dynamic registry: add/remove/clear/list
|
|
319
387
|
│ └── tags.h # TAG_* bit constants
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
322
400
|
```
|
|
323
401
|
|
|
324
402
|
## Requirements
|
|
@@ -393,6 +471,45 @@ Or compile and test in one step:
|
|
|
393
471
|
bundle exec rake
|
|
394
472
|
```
|
|
395
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
|
+
|
|
396
513
|
## How it works
|
|
397
514
|
|
|
398
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.
|
|
@@ -423,3 +540,4 @@ Released under the [MIT License](LICENSE).
|
|
|
423
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.
|
|
424
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.
|
|
425
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
|
+
- **Performance is currently slower than pure-Ruby `gsub`.** A May 2026 investigation found the C extension is 3–5× slower than a pure-Ruby `gsub` loop running the same 88 patterns, across input sizes from 168 bytes to 1 MB. The root cause is glibc's POSIX `regexec()`: each call allocates an O(input-length) state buffer before any matching begins, and the gem calls it once per pattern in sequence. Ruby's Onigmo engine wins by using a built-in Boyer-Moore literal pre-filter that this gem can only approximate. Two perf fixes have shipped (buffer-sizing in `replace_all_matches`, a `strstr` literal pre-filter, and input chunking for large payloads), which gave ~25-30% improvement and made scaling linear, but the absolute gap remains. Use the gem on small payloads where the absolute latency is still acceptable (< 1 ms for typical log lines); for high-throughput pipelines, hold off until the next major release. See `docs/standalone_matcher_design.md` for the long-term plan.
|
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.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Daniele Frisanco
|
|
@@ -65,6 +65,34 @@ dependencies:
|
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '2.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: benchmark-ips
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.13'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.13'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: benchmark-memory
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.2'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.2'
|
|
68
96
|
description: A Ruby gem with a C extension for high-performance scanning and redaction
|
|
69
97
|
of 85 sensitive patterns — API keys, tokens, credentials, IBANs, national IDs, emails,
|
|
70
98
|
phone numbers, and PII from 15+ countries. Optional Logger formatter, Rails filter_parameters
|
|
@@ -88,6 +116,7 @@ files:
|
|
|
88
116
|
- lib/data_redactor/integrations/logger.rb
|
|
89
117
|
- lib/data_redactor/integrations/rack.rb
|
|
90
118
|
- lib/data_redactor/integrations/rails.rb
|
|
119
|
+
- lib/data_redactor/name_pattern.rb
|
|
91
120
|
- lib/data_redactor/version.rb
|
|
92
121
|
- readme.md
|
|
93
122
|
homepage: https://github.com/danielefrisanco/data_redactor
|