data_redactor 0.8.0 → 0.9.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: aae84ce43ab8d2ad6751ade655397480057d687bdff7a6b857ee2821dffeb91b
4
- data.tar.gz: 6e01ebe9d76e64ac3a93c31f14f7089ddaff4645e0819dec675d7585e37c4078
3
+ metadata.gz: 007d59e430d1675a13b84670f6c34c300f8b72fd7ee4744aa191f846bb89b072
4
+ data.tar.gz: a23f3b99c3ead341d2c9415a1b4b2eb32a45ee002f052a8e58d928eb1ce03919
5
5
  SHA512:
6
- metadata.gz: a80b34b6e35fdf97cca2d9fecf1cb136b0e0c676ca1e3080c3680aaeb41b442cb2400c371e38417703910fc023ad01a3cf61f2f4a8f8dc5d4bd681174420d2b4
7
- data.tar.gz: 4dbf049d027385c21a721044ac6651f4b24b33500af98a0c4c88d7860c06eb2721ea5aab4b8793f6fcd5614cac0cc645c1f67e73b5ebf8d7ff04ead58b67a244
6
+ metadata.gz: ccd4f6f97a0110585e4f43f9402eac2a1f57b2aef01a3c6870f0e57ea578377291a7367ee924585d9d11e92af98f4178bb0b9488c1a24a2338f6a41936efad30
7
+ data.tar.gz: 5281171119b4892167a6b1d55e0996db47408c8a6d334656998f8f2ca50794a3a7b5c987132369ca32965da0943f954eab61f34f5a97c683b8a14851e9beca1e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-05-22
11
+
12
+ ### Added
13
+ - `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.
14
+
15
+ ## [0.8.0] - 2026-05-21
16
+
10
17
  ### Added
11
18
  - `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
19
  - `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 +177,9 @@ features as 0.7.1 plus the pipeline fix.
170
177
  - `DataRedactor.redact(text)` module function returning the input with every match replaced by `[REDACTED]`.
171
178
  - RSpec suite with one example per pattern.
172
179
 
173
- [Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.2...HEAD
180
+ [Unreleased]: https://github.com/danielefrisanco/data_redactor/compare/v0.9.0...HEAD
181
+ [0.9.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.8.0...v0.9.0
182
+ [0.8.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.2...v0.8.0
174
183
  [0.7.2]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.1...v0.7.2
175
184
  [0.7.1]: https://github.com/danielefrisanco/data_redactor/compare/v0.7.0...v0.7.1
176
185
  [0.7.0]: https://github.com/danielefrisanco/data_redactor/compare/v0.6.1...v0.7.0
@@ -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
@@ -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.8.0"
3
+ VERSION = "0.9.0"
4
4
  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
  #
data/readme.md CHANGED
@@ -8,7 +8,32 @@ 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 patterns and replaces matches with `[REDACTED]`. It uses a C extension backed by POSIX `regex.h` so the heavy lifting happens outside the Ruby VM, making it fast enough for large payloads.
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 POSIX
14
+ `regex.h`, so the heavy lifting happens outside the Ruby VM and stays fast enough
15
+ to run inline on large payloads.
16
+
17
+ It ships **88 built-in patterns** across 15+ countries, grouped into tags
18
+ (`:credentials`, `:financial`, `:contact`, ...) so you can redact only what you
19
+ care about. Beyond plain strings it can walk nested Hashes, Arrays, and JSON,
20
+ audit a payload without mutating it (`scan`), and plug into Logger, Rails, and
21
+ Rack. You can also register your own patterns at boot.
22
+
23
+ ### Use cases
24
+
25
+ - **Log scrubbing** — drop the `Logger` formatter in so no secret or PII ever
26
+ reaches disk or your log aggregator.
27
+ - **Rails parameter filtering** — feed `filter_parameters` a redactor-backed proc
28
+ to keep request params out of logs and error reports.
29
+ - **HTTP request/response sanitising** — Rack middleware scrubs response bodies
30
+ and sensitive headers in flight.
31
+ - **Sanitising LLM / API payloads** — run `redact_deep` over a params hash or
32
+ `redact_json` over a JSON body before it leaves the process.
33
+ - **Compliance & auditing** — `scan` reports every match with byte offsets, tag,
34
+ and pattern name without changing the text, for false-positive tuning.
35
+ - **Internal identifiers** — register company-specific patterns (`add_pattern`)
36
+ or generate them from a person's name (`name_pattern`).
12
37
 
13
38
  ## Usage
14
39
 
@@ -158,6 +183,46 @@ DataRedactor.clear_custom_patterns! # mostly for test suites
158
183
 
159
184
  **`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
185
 
186
+ ### Name patterns
187
+
188
+ Personal names can't ship as built-ins — every team has different ones — but the regex
189
+ boilerplate to match a name across its written variations is the same every time.
190
+ `name_pattern` generates that regex for you, ready to hand to `add_pattern`:
191
+
192
+ ```ruby
193
+ DataRedactor.add_pattern(
194
+ name: "person_mario_rossi",
195
+ regex: DataRedactor.name_pattern("Mario", "Rossi"),
196
+ tag: :contact
197
+ )
198
+
199
+ DataRedactor.redact("ticket from Mario Rossi about ...")
200
+ # => "ticket from [REDACTED] about ..."
201
+ ```
202
+
203
+ A single generated pattern matches all of these:
204
+
205
+ - **Case** — `Mario Rossi`, `mario rossi`, `MARIO ROSSI`
206
+ - **Order** — `Mario Rossi`, `Rossi Mario`, `Rossi, Mario`, `Rossi,Mario`
207
+ - **Initials** — `M. Rossi`, `M Rossi`, `Mario R.`, `M.R.`, `MR`
208
+ - **Diacritics** — `name_pattern("Jose", "Munoz")` also matches `José Muñoz` (and vice versa)
209
+ - **Separators** — spaces and hyphens are interchangeable. `name_pattern("Anne-Marie", "Berg")`
210
+ matches `Anne-Marie Berg`, `Anne Marie Berg`, `AnneMarie Berg`, and each half alone
211
+ (`Anne Berg`, `Marie Berg`). Multi-word parts like `"Van der Berg"` tolerate any
212
+ space/hyphen separator between words.
213
+
214
+ It does **not** match a name embedded in a longer word — `Mario` will not fire inside
215
+ `Mariolino` — because the generated pattern is boundary-wrapped. For that reason, register
216
+ it with the default `boundary: false` (the wrapper is already baked into the returned
217
+ string; `boundary: true` would double-wrap and reject its capture groups).
218
+
219
+ Pass `middle:` to also cover a middle name — both the no-middle and with-middle forms match:
220
+
221
+ ```ruby
222
+ DataRedactor.name_pattern("Mario", "Rossi", middle: "Luigi")
223
+ # matches "Mario Rossi" AND "Mario Luigi Rossi" AND "Rossi Mario Luigi"
224
+ ```
225
+
161
226
  ## Integrations
162
227
 
163
228
  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 +371,9 @@ redactor/
306
371
  ├── lib/
307
372
  │ ├── data_redactor.rb # Ruby entry point, loads the .so
308
373
  │ └── data_redactor/
309
- └── version.rb
374
+ ├── version.rb
375
+ │ ├── name_pattern.rb # name_pattern helper — generates a name regex for add_pattern
376
+ │ └── integrations/ # soft-required Logger / Rails / Rack adapters
310
377
  ├── ext/
311
378
  │ └── data_redactor/
312
379
  │ ├── extconf.rb # Checks for C headers, generates Makefile (globs *.c)
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniele Frisanco
@@ -110,6 +110,7 @@ files:
110
110
  - lib/data_redactor/integrations/logger.rb
111
111
  - lib/data_redactor/integrations/rack.rb
112
112
  - lib/data_redactor/integrations/rails.rb
113
+ - lib/data_redactor/name_pattern.rb
113
114
  - lib/data_redactor/version.rb
114
115
  - readme.md
115
116
  homepage: https://github.com/danielefrisanco/data_redactor