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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE +21 -0
- data/ext/data_redactor/data_redactor.c +1047 -0
- data/ext/data_redactor/extconf.rb +8 -0
- data/lib/data_redactor/version.rb +3 -0
- data/lib/data_redactor.rb +145 -0
- data/readme.md +269 -0
- metadata +87 -0
|
@@ -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,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: []
|