data_redactor 0.5.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.
@@ -0,0 +1,8 @@
1
+ require "mkmf"
2
+
3
+ abort "Missing C compiler or stdio.h" unless have_header("stdio.h")
4
+ abort "Missing regex.h" unless have_header("regex.h")
5
+ abort "Missing stdlib.h" unless have_header("stdlib.h")
6
+ abort "Missing string.h" unless have_header("string.h")
7
+
8
+ create_makefile("data_redactor/data_redactor")
@@ -0,0 +1,3 @@
1
+ module DataRedactor
2
+ VERSION = "0.5.0"
3
+ end
@@ -0,0 +1,145 @@
1
+ require_relative "data_redactor/version"
2
+ require_relative "data_redactor/data_redactor" # loads the compiled .so
3
+
4
+ module DataRedactor
5
+ TAGS = {
6
+ credentials: TAG_CREDENTIALS,
7
+ financial: TAG_FINANCIAL,
8
+ tax_id: TAG_TAX_ID,
9
+ national_id: TAG_NATIONAL_ID,
10
+ contact: TAG_CONTACT,
11
+ network: TAG_NETWORK,
12
+ travel: TAG_TRAVEL,
13
+ other: TAG_OTHER,
14
+ custom: TAG_CUSTOM
15
+ }.freeze
16
+
17
+ class UnknownTagError < ArgumentError; end
18
+ class InvalidPatternError < ArgumentError; end
19
+
20
+ # Capture groups break boundary-wrapper group index assumptions ([1],[2],[3] shift).
21
+ CAPTURE_GROUP_RE = /(?<!\\)\((?!\?:)/.freeze
22
+
23
+ # Ruby regex syntax that has no POSIX ERE equivalent.
24
+ RUBY_ONLY_SYNTAX_RE = /\\[dDwWsShHbB]|\(\?[<!=]|\(\?<[a-zA-Z]|\(\?[imx]|[*+?]\?/.freeze
25
+
26
+ PLACEHOLDER_DEFAULT = "[REDACTED]"
27
+
28
+ module_function
29
+
30
+ def tags
31
+ TAGS.keys
32
+ end
33
+
34
+ def redact(text, only: nil, except: nil, placeholder: PLACEHOLDER_DEFAULT)
35
+ raise ArgumentError, "pass only: or except:, not both" if only && except
36
+
37
+ mask =
38
+ if only
39
+ bits_for(only)
40
+ elsif except
41
+ TAG_ALL & ~bits_for(except)
42
+ else
43
+ TAG_ALL
44
+ end
45
+
46
+ ph_mode, ph_str = resolve_placeholder(placeholder)
47
+ _redact(text, mask, ph_mode, ph_str)
48
+ end
49
+
50
+ # Scan text without necessarily redacting it.
51
+ #
52
+ # Returns { redacted: String, matches: [{tag:, name:, value:, start:, length:}, ...] }
53
+ # The :tag value is a Symbol matching one of DataRedactor.tags.
54
+ # :start and :length are byte offsets into the original string.
55
+ def scan(text, only: nil, except: nil)
56
+ raise ArgumentError, "pass only: or except:, not both" if only && except
57
+
58
+ mask =
59
+ if only
60
+ bits_for(only)
61
+ elsif except
62
+ TAG_ALL & ~bits_for(except)
63
+ else
64
+ TAG_ALL
65
+ end
66
+
67
+ result = _scan(text, mask)
68
+ # Normalise: convert tag string from C (uppercase) back to the Symbol used in TAGS
69
+ result[:matches].each do |m|
70
+ m[:tag] = m[:tag].to_s.downcase.to_sym
71
+ end
72
+ result
73
+ end
74
+
75
+ # Add (or replace) a custom redaction pattern.
76
+ #
77
+ # name: unique identifier string
78
+ # regex: String (POSIX ERE) or Regexp; Ruby-only syntax raises InvalidPatternError
79
+ # tag: one of the TAGS keys (default :custom), or any built-in tag
80
+ # boundary: wrap with word-boundary guards; incompatible with capture groups
81
+ def add_pattern(name:, regex:, tag: :custom, boundary: false)
82
+ raise ArgumentError, "name must be a non-empty String" \
83
+ unless name.is_a?(String) && !name.empty?
84
+
85
+ source = case regex
86
+ when String then regex
87
+ when Regexp then regex.source
88
+ else raise ArgumentError, "regex must be a String or Regexp, got #{regex.class}"
89
+ end
90
+
91
+ if source =~ RUBY_ONLY_SYNTAX_RE
92
+ raise InvalidPatternError,
93
+ "pattern #{name.inspect} uses Ruby-only syntax (#{$&.inspect}); " \
94
+ "use POSIX ERE — no \\d, \\s, \\w, \\b, lookaround, non-greedy, or named groups"
95
+ end
96
+
97
+ if boundary && source =~ CAPTURE_GROUP_RE
98
+ raise InvalidPatternError,
99
+ "pattern #{name.inspect} has capture groups and cannot use boundary: true"
100
+ end
101
+
102
+ tag_bit = TAGS[tag] or raise UnknownTagError,
103
+ "unknown tag #{tag.inspect}; valid tags: #{TAGS.keys.inspect}"
104
+
105
+ _add_pattern(name, source, tag_bit, boundary ? 1 : 0)
106
+ end
107
+
108
+ def remove_pattern(name)
109
+ _remove_pattern(name.to_s)
110
+ end
111
+
112
+ def custom_patterns
113
+ _custom_patterns.map do |h|
114
+ { name: h[:name], source: h[:source], tag: TAGS.key(h[:tag_bit]) || :custom,
115
+ boundary: h[:boundary] }
116
+ end
117
+ end
118
+
119
+ def clear_custom_patterns!
120
+ _clear_custom_patterns
121
+ end
122
+
123
+ def bits_for(tag_list)
124
+ Array(tag_list).inject(0) do |acc, tag|
125
+ bit = TAGS[tag] or raise UnknownTagError,
126
+ "unknown tag #{tag.inspect}; valid tags: #{TAGS.keys.inspect}"
127
+ acc | bit
128
+ end
129
+ end
130
+
131
+ # Returns [ph_mode_int, ph_str] for the C layer.
132
+ # placeholder: "***" -> plain string
133
+ # placeholder: :tagged -> "[REDACTED:TAGNAME]"
134
+ # placeholder: :hash -> "[TAGNAME_xxxx]"
135
+ def resolve_placeholder(placeholder)
136
+ case placeholder
137
+ when :tagged then [PH_MODE_TAGGED, ""]
138
+ when :hash then [PH_MODE_HASH, ""]
139
+ when String then [PH_MODE_PLAIN, placeholder]
140
+ else
141
+ raise ArgumentError,
142
+ "placeholder must be a String, :tagged, or :hash — got #{placeholder.inspect}"
143
+ end
144
+ end
145
+ end
data/readme.md ADDED
@@ -0,0 +1,269 @@
1
+ # DataRedactor
2
+
3
+ A Ruby gem with a C extension for high-performance regex-based redaction of sensitive data from strings.
4
+
5
+ ## What it does
6
+
7
+ 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.
8
+
9
+ ## Usage
10
+
11
+ ```ruby
12
+ require "data_redactor"
13
+
14
+ text = "User CF is RSSMRA85M01H501Z and key is AKIAIOSFODNN7EXAMPLE"
15
+ DataRedactor.redact(text)
16
+ # => "User CF is [REDACTED] and key is [REDACTED]"
17
+ ```
18
+
19
+ ### Filtering by tag
20
+
21
+ Every pattern belongs to one tag. Use `only:` to redact a subset, or `except:` to skip one.
22
+
23
+ ```ruby
24
+ DataRedactor.tags
25
+ # => [:credentials, :financial, :tax_id, :national_id, :contact, :network, :travel, :other]
26
+
27
+ # Only redact API keys / tokens / private keys
28
+ DataRedactor.redact(text, only: [:credentials])
29
+
30
+ # Redact everything except contact info (emails, phone numbers)
31
+ DataRedactor.redact(text, except: [:contact])
32
+
33
+ # Single symbol works too
34
+ DataRedactor.redact(text, only: :financial)
35
+ ```
36
+
37
+ Passing an unknown tag raises `DataRedactor::UnknownTagError`. Passing both `only:` and `except:` raises `ArgumentError`.
38
+
39
+ ### Configurable placeholder
40
+
41
+ By default every match is replaced with `[REDACTED]`. Use the `placeholder:` keyword to change this:
42
+
43
+ ```ruby
44
+ # Plain string — any replacement text
45
+ DataRedactor.redact(text, placeholder: "***")
46
+ DataRedactor.redact(text, placeholder: "")
47
+
48
+ # Tagged — embeds the pattern's tag name so you know what was redacted
49
+ DataRedactor.redact(text, placeholder: :tagged)
50
+ # "user@example.com" → "[REDACTED:CONTACT]"
51
+ # "AKIAIOSFODNN7EXAMPLE" → "[REDACTED:CREDENTIALS]"
52
+ # "DE89370400440532013000" → "[REDACTED:FINANCIAL]"
53
+
54
+ # Hash — deterministic 4-hex suffix of the matched value
55
+ # Same value always produces the same token — useful for correlating
56
+ # redactions across log lines without leaking the original.
57
+ DataRedactor.redact(text, placeholder: :hash)
58
+ # "user@example.com" → "[CONTACT_3d7a]"
59
+ # "user@example.com" → "[CONTACT_3d7a]" (same every time)
60
+ # "other@example.com" → "[CONTACT_91fc]" (different value, different hash)
61
+ ```
62
+
63
+ All three modes compose with `only:` and `except:`:
64
+
65
+ ```ruby
66
+ DataRedactor.redact(text, only: :contact, placeholder: :tagged)
67
+ ```
68
+
69
+ ### Scan / dry-run mode
70
+
71
+ `DataRedactor.scan` returns every match alongside the redacted string — useful for auditing, tuning false positives, and compliance pipelines:
72
+
73
+ ```ruby
74
+ result = DataRedactor.scan("User AKIAIOSFODNN7EXAMPLE logged in from 192.168.1.1")
75
+ # => {
76
+ # redacted: "User [REDACTED] logged in from [REDACTED]",
77
+ # matches: [
78
+ # { tag: :credentials, name: "aws_access_key_id", value: "AKIAIOSFODNN7EXAMPLE", start: 5, length: 20 },
79
+ # { tag: :network, name: "ipv4", value: "192.168.1.1", start: 35, length: 11 }
80
+ # ]
81
+ # }
82
+
83
+ # :start and :length are byte offsets into the original string
84
+ m = result[:matches].first
85
+ original_text.byteslice(m[:start], m[:length]) # => "AKIAIOSFODNN7EXAMPLE"
86
+
87
+ # Accepts the same tag filters as redact
88
+ DataRedactor.scan(text, only: :credentials)
89
+ DataRedactor.scan(text, except: :network)
90
+ ```
91
+
92
+ ### Custom patterns
93
+
94
+ Teams often have internal IDs that the gem can't ship. Register them at boot:
95
+
96
+ ```ruby
97
+ # String (POSIX ERE) or Regexp — both accepted
98
+ DataRedactor.add_pattern(name: "employee_id", regex: "EMP-[0-9]{6}")
99
+ DataRedactor.add_pattern(name: "ticket_ref", regex: /TICKET-[A-Z]{2}[0-9]{4}/, boundary: true)
100
+
101
+ # Custom patterns are tagged :custom by default; pass any built-in tag to group differently
102
+ DataRedactor.add_pattern(name: "internal_key", regex: "INT-[A-Z]{3}", tag: :credentials)
103
+
104
+ DataRedactor.redact(text) # runs all patterns including custom
105
+ DataRedactor.redact(text, only: [:custom]) # only user patterns
106
+ DataRedactor.redact(text, only: [:custom, :credentials]) # mix
107
+
108
+ DataRedactor.custom_patterns # => [{name:, source:, tag:, boundary:}, ...]
109
+ DataRedactor.remove_pattern("employee_id")
110
+ DataRedactor.clear_custom_patterns! # mostly for test suites
111
+ ```
112
+
113
+ **Regex rules** — patterns must be POSIX ERE (the same engine used for built-ins). Not supported: `\d`, `\s`, `\w`, `\b`, lookahead/lookbehind, non-greedy quantifiers, named groups. Violations raise `DataRedactor::InvalidPatternError` at registration time, never at redaction time. Use `[0-9]` instead of `\d`, `[[:space:]]` instead of `\s`, etc.
114
+
115
+ **`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.
116
+
117
+ ## Detected patterns (49 total)
118
+
119
+ ### Cloud & API secrets
120
+
121
+ | # | Pattern | Example |
122
+ |---|---|---|
123
+ | 0 | AWS Access Key ID | `AKIAIOSFODNN7EXAMPLE` |
124
+ | 1 | AWS Secret Access Key | 40-character base64 string |
125
+ | 5 | Google API Key | `AIzaSyXXXX...` |
126
+ | 6 | GitHub Personal Access Token | `github_pat_XXXX...` |
127
+ | 7 | Slack Webhook URL | `https://hooks.slack.com/services/T.../B.../...` |
128
+ | 8 | Stripe Secret Key | `sk_live_XXXX...` |
129
+ | 9 | PEM Private Key header | `-----BEGIN RSA PRIVATE KEY-----` |
130
+ | 13 | Scaleway Access Key | `SCW12345ABCDE6789FGHIJ` |
131
+ | 14 | UUID v4 / Scaleway Secret Key | `550e8400-e29b-41d4-a716-446655440000` |
132
+
133
+ ### Travel documents
134
+
135
+ | # | Pattern | Example |
136
+ |---|---|---|
137
+ | 2 | Italian Codice Fiscale (basic) | `RSSMRA85M01H501Z` |
138
+ | 3 | Passport — letter prefix + digits | `AB1234567` |
139
+ | 4 | Passport — 9 consecutive digits ¹ | `123456789` |
140
+ | 22 | Italian Codice Fiscale (omocodia) | `RSSMRALPMNLH5LMZ` |
141
+
142
+ ### Payment & network
143
+
144
+ | # | Pattern | Example |
145
+ |---|---|---|
146
+ | 11 | Credit card — Visa, Mastercard, Amex, Discover, JCB | `4111111111111111` |
147
+ | 12 | IPv4 address | `192.168.1.100` |
148
+
149
+ ### IBANs
150
+
151
+ | # | Country | Example |
152
+ |---|---|---|
153
+ | 10 | Italy | `IT60X0542811101000000123456` |
154
+ | 15 | France | `FR7630006000011234567890189` |
155
+ | 16 | Germany | `DE89370400440532013000` |
156
+ | 17 | Spain | `ES9121000418450200051332` |
157
+ | 18 | Netherlands | `NL91ABNA0417164300` |
158
+ | 19 | Belgium | `BE68539007547034` |
159
+ | 20 | Portugal | `PT50000201231234567890154` |
160
+ | 21 | Ireland | `IE29AIBK93115212345678` |
161
+ | 28 | Sweden | `SE4550000000058398257466` |
162
+ | 29 | Denmark | `DK5000400440116243` |
163
+ | 30 | Norway | `NO9386011117947` |
164
+ | 31 | Finland | `FI2112345600000785` |
165
+ | 37 | Poland | `PL61109010140000071219812874` |
166
+ | 38 | Austria | `AT611904300234573201` |
167
+ | 39 | Switzerland | `CH9300762011623852957` |
168
+ | 40 | Czechia | `CZ6508000000192000145399` |
169
+ | 41 | Hungary | `HU42117730161111101800000000` |
170
+ | 42 | Romania | `RO49AAAA1B31007593840000` |
171
+
172
+ ### National personal identifiers
173
+
174
+ | # | Country | Type | Example |
175
+ |---|---|---|---|
176
+ | 23 | France | NIR / Social Security ¹ | `185126203450342` |
177
+ | 24 | Spain | DNI ¹ | `12345678Z` |
178
+ | 25 | Spain | NIE | `X1234567L` |
179
+ | 26 | Netherlands | BSN ¹ | `123456789` |
180
+ | 27 | Poland | PESEL ¹ | `85121612345` |
181
+ | 32 | Belgium | National Number ¹ | `85121612345` |
182
+ | 33 | Sweden | Personnummer ¹ | `850101-1234` |
183
+ | 34 | Denmark | CPR Number ¹ | `010185-1234` |
184
+ | 35 | Norway | Fødselsnummer ¹ | `01018512345` |
185
+ | 36 | Finland | HETU ¹ | `010185-123A` |
186
+ | 43 | Poland | PESEL (alt slot) ¹ | `90010112345` |
187
+ | 44 | Austria | Abgabenkontonummer ¹ | `123456789` |
188
+ | 45 | Switzerland | AHV Number ¹ | `756.1234.5678.90` |
189
+ | 46 | Czechia | Rodné číslo ¹ | `856121/1234` |
190
+ | 47 | Hungary | Tax ID ¹ | `8012345678` |
191
+ | 48 | Romania | CNP ¹ | `1850101123456` |
192
+
193
+ > ¹ **Word-boundary protected** — these patterns are wrapped with `(^|[^0-9A-Za-z])(PATTERN)([^0-9A-Za-z]|$)` at compile time so they do not fire when the digit sequence appears inside a longer alphanumeric token.
194
+
195
+ ## Directory structure
196
+
197
+ ```
198
+ redactor/
199
+ ├── data_redactor.gemspec
200
+ ├── Gemfile
201
+ ├── Rakefile
202
+ ├── lib/
203
+ │ ├── data_redactor.rb # Ruby entry point, loads the .so
204
+ │ └── data_redactor/
205
+ │ └── version.rb
206
+ ├── ext/
207
+ │ └── data_redactor/
208
+ │ ├── extconf.rb # Checks for C headers, generates Makefile
209
+ │ └── data_redactor.c # C extension: regex compilation + redaction
210
+ └── spec/
211
+ └── data_redactor_spec.rb # RSpec tests (61 examples, one per pattern)
212
+ ```
213
+
214
+ ## Requirements
215
+
216
+ - Ruby >= 2.7
217
+ - A C compiler (`gcc` or `clang`)
218
+ - POSIX `regex.h` (standard on Linux and macOS)
219
+
220
+ ## Setup
221
+
222
+ ```bash
223
+ bundle install
224
+ ```
225
+
226
+ ## Compile the C extension
227
+
228
+ ```bash
229
+ bundle exec rake compile
230
+ ```
231
+
232
+ This runs `extconf.rb` via `rake-compiler`, which generates a `Makefile` and compiles `data_redactor.c` into a `.so` shared library placed under `lib/data_redactor/`.
233
+
234
+ ## Run the tests
235
+
236
+ ```bash
237
+ bundle exec rake spec
238
+ ```
239
+
240
+ Or compile and test in one step:
241
+
242
+ ```bash
243
+ bundle exec rake
244
+ ```
245
+
246
+ ## How it works
247
+
248
+ 1. At load time, `Init_data_redactor` compiles all 49 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.
249
+ 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.
250
+ 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.
251
+ 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.
252
+
253
+ ## Memory management
254
+
255
+ 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.
256
+
257
+ ## Versioning
258
+
259
+ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). Until `1.0.0`, minor versions may introduce breaking changes; from `1.0.0` onward, breaking changes will only land in major versions. See [CHANGELOG.md](CHANGELOG.md) for the release history.
260
+
261
+ ## License
262
+
263
+ Released under the [MIT License](LICENSE).
264
+
265
+ ## Known limitations
266
+
267
+ - **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.
268
+ - **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.
269
+ - **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.
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: data_redactor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniele Frisanco
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake-compiler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.2'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.12'
41
+ description: A Ruby gem with a C extension for high-performance scanning and redaction
42
+ of 79 sensitive patterns — API keys, tokens, credentials, IBANs, national IDs, emails,
43
+ phone numbers, and PII from 15+ countries. Designed to sanitize text before sending
44
+ to LLMs, logging systems, or any public/third-party API.
45
+ email:
46
+ - daniele.frisanco@gmail.com
47
+ executables: []
48
+ extensions:
49
+ - ext/data_redactor/extconf.rb
50
+ extra_rdoc_files: []
51
+ files:
52
+ - CHANGELOG.md
53
+ - LICENSE
54
+ - ext/data_redactor/data_redactor.c
55
+ - ext/data_redactor/extconf.rb
56
+ - lib/data_redactor.rb
57
+ - lib/data_redactor/version.rb
58
+ - readme.md
59
+ homepage: https://github.com/danielefrisanco/data_redactor
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/danielefrisanco/data_redactor
64
+ source_code_uri: https://github.com/danielefrisanco/data_redactor
65
+ changelog_uri: https://github.com/danielefrisanco/data_redactor/blob/main/CHANGELOG.md
66
+ bug_tracker_uri: https://github.com/danielefrisanco/data_redactor/issues
67
+ rubygems_mfa_required: 'true'
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '2.7'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.5.22
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Redact PII and secrets from strings before sending to AI or external services
87
+ test_files: []