data_redactor 0.8.0 → 0.10.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 +4 -4
- data/CHANGELOG.md +37 -1
- data/ext/data_redactor/custom_patterns.c +5 -0
- data/ext/data_redactor/data_redactor.c +15 -1
- data/ext/data_redactor/matcher.c +1193 -0
- data/ext/data_redactor/matcher.h +78 -0
- data/ext/data_redactor/patterns.c +119 -0
- data/ext/data_redactor/patterns.h +11 -0
- data/ext/data_redactor/redact.c +106 -33
- data/ext/data_redactor/scan.c +141 -92
- 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 +32 -1
data/ext/data_redactor/scan.c
CHANGED
|
@@ -3,22 +3,12 @@
|
|
|
3
3
|
#include "placeholder.h"
|
|
4
4
|
#include "custom_patterns.h"
|
|
5
5
|
#include "redact.h"
|
|
6
|
+
#include "matcher.h"
|
|
7
|
+
#include "tags.h"
|
|
6
8
|
#include <regex.h>
|
|
7
9
|
#include <string.h>
|
|
8
10
|
#include <stdlib.h>
|
|
9
11
|
|
|
10
|
-
/*
|
|
11
|
-
* To map working-buffer positions back to original-string positions we
|
|
12
|
-
* maintain a log of every replacement already applied. Each entry records
|
|
13
|
-
* where in the *working* buffer the replacement started (after all prior
|
|
14
|
-
* replacements) and how many bytes were removed (orig_len) vs. inserted
|
|
15
|
-
* (always 10, the length of "[REDACTED]").
|
|
16
|
-
*
|
|
17
|
-
* For a new match at working position W:
|
|
18
|
-
* cumulative_shift_before_W = sum of (10 - orig_len) for all prior
|
|
19
|
-
* replacements whose working_pos <= W
|
|
20
|
-
* original_pos = W - cumulative_shift_before_W
|
|
21
|
-
*/
|
|
22
12
|
/* Look up the i-th entry of the enable_bits Array. Out-of-bounds → 0 (skip). */
|
|
23
13
|
static inline int scan_enable_bit(VALUE rb_enable_bits, long i) {
|
|
24
14
|
if (i < 0 || i >= RARRAY_LEN(rb_enable_bits)) return 0;
|
|
@@ -26,105 +16,164 @@ static inline int scan_enable_bit(VALUE rb_enable_bits, long i) {
|
|
|
26
16
|
return RTEST(v) && NUM2INT(v) != 0;
|
|
27
17
|
}
|
|
28
18
|
|
|
19
|
+
/*
|
|
20
|
+
* Map a working-buffer position (after built-in redaction) back to the
|
|
21
|
+
* original-input position.
|
|
22
|
+
*
|
|
23
|
+
* After the built-in pass, the working buffer contains the original input
|
|
24
|
+
* with each matched CORE span replaced by "[REDACTED]" (10 bytes). The
|
|
25
|
+
* ev[] array (sorted by start, non-overlapping) records every replacement
|
|
26
|
+
* in original-frame coordinates. Walking ev[] we can find which verbatim
|
|
27
|
+
* segment or replacement a working position falls in and recover the
|
|
28
|
+
* original position.
|
|
29
|
+
*
|
|
30
|
+
* For a match that lands inside a "[REDACTED]" span we return the start of
|
|
31
|
+
* the corresponding original CORE (can only happen if a custom pattern
|
|
32
|
+
* matches the literal "[REDACTED]" itself, which is a degenerate case).
|
|
33
|
+
*/
|
|
34
|
+
static long working_to_orig(long wpos, const mm_match_t *ev, size_t n,
|
|
35
|
+
size_t ph_len) {
|
|
36
|
+
long cum_orig = 0;
|
|
37
|
+
long cum_work = 0;
|
|
38
|
+
for (size_t i = 0; i < n; i++) {
|
|
39
|
+
long seg = (long)ev[i].start - cum_orig;
|
|
40
|
+
if (wpos < cum_work + seg)
|
|
41
|
+
return cum_orig + (wpos - cum_work);
|
|
42
|
+
cum_orig += seg + (long)ev[i].length;
|
|
43
|
+
cum_work += seg + (long)ph_len;
|
|
44
|
+
}
|
|
45
|
+
return cum_orig + (wpos - cum_work);
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
VALUE rb_data_redactor_scan(VALUE self, VALUE rb_text, VALUE rb_enable_bits) {
|
|
30
49
|
Check_Type(rb_text, T_STRING);
|
|
31
50
|
Check_Type(rb_enable_bits, T_ARRAY);
|
|
32
51
|
|
|
33
|
-
const char *input
|
|
52
|
+
const char *input = RSTRING_PTR(rb_text);
|
|
53
|
+
size_t in_len = (size_t)RSTRING_LEN(rb_text);
|
|
54
|
+
|
|
55
|
+
static const placeholder_t ph_plain = { PLACEHOLDER_MODE_PLAIN, "[REDACTED]" };
|
|
56
|
+
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/* Stage 1: built-ins through v19 (original-frame coords, no rewrite */
|
|
59
|
+
/* coordinate mapping needed). */
|
|
60
|
+
/* ------------------------------------------------------------------ */
|
|
34
61
|
|
|
35
|
-
|
|
62
|
+
/* Build enable-bits array for built-ins. */
|
|
63
|
+
int *bits = (int *)malloc((size_t)NUM_PATTERNS * sizeof(int));
|
|
64
|
+
if (!bits) rb_raise(rb_eNoMemError, "enable_bits allocation failed");
|
|
65
|
+
long alen = RARRAY_LEN(rb_enable_bits);
|
|
66
|
+
for (int i = 0; i < NUM_PATTERNS; i++) {
|
|
67
|
+
if (i < alen) {
|
|
68
|
+
VALUE v = rb_ary_entry(rb_enable_bits, i);
|
|
69
|
+
bits[i] = (RTEST(v) && NUM2INT(v) != 0) ? 1 : 0;
|
|
70
|
+
} else {
|
|
71
|
+
bits[i] = 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
36
74
|
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
/* Scan + resolve, growing buffer if needed. */
|
|
76
|
+
size_t cap = in_len / 4 + 16;
|
|
77
|
+
mm_match_t *ev = NULL;
|
|
78
|
+
size_t n_ev;
|
|
79
|
+
for (;;) {
|
|
80
|
+
mm_match_t *grown = (mm_match_t *)realloc(ev, cap * sizeof(mm_match_t));
|
|
81
|
+
if (!grown) { free(ev); free(bits); rb_raise(rb_eNoMemError, "mm_scan alloc"); }
|
|
82
|
+
ev = grown;
|
|
83
|
+
n_ev = mm_scan(input, in_len, bits, (size_t)NUM_PATTERNS, ev, cap);
|
|
84
|
+
if (n_ev < cap) break;
|
|
85
|
+
cap *= 2;
|
|
86
|
+
}
|
|
87
|
+
free(bits);
|
|
88
|
+
n_ev = mm_resolve(ev, n_ev);
|
|
39
89
|
|
|
90
|
+
/* Collect built-in match hashes. */
|
|
40
91
|
VALUE matches_arr = rb_ary_new();
|
|
92
|
+
for (size_t i = 0; i < n_ev; i++) {
|
|
93
|
+
int pid = ev[i].pattern_id;
|
|
94
|
+
VALUE h = rb_hash_new();
|
|
95
|
+
rb_hash_aset(h, ID2SYM(rb_intern("tag")),
|
|
96
|
+
ID2SYM(rb_intern(tag_name_for_bit(pattern_tags[pid]))));
|
|
97
|
+
rb_hash_aset(h, ID2SYM(rb_intern("name")),
|
|
98
|
+
rb_str_new_cstr(pattern_names[pid]));
|
|
99
|
+
rb_hash_aset(h, ID2SYM(rb_intern("value")),
|
|
100
|
+
rb_str_new(input + ev[i].start, ev[i].length));
|
|
101
|
+
rb_hash_aset(h, ID2SYM(rb_intern("start")),
|
|
102
|
+
LONG2NUM((long)ev[i].start));
|
|
103
|
+
rb_hash_aset(h, ID2SYM(rb_intern("length")),
|
|
104
|
+
LONG2NUM((long)ev[i].length));
|
|
105
|
+
rb_ary_push(matches_arr, h);
|
|
106
|
+
}
|
|
41
107
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#define REPL_LOG_PUSH(_wpos, _olen) do { \
|
|
48
|
-
if (repl_count >= repl_cap) { \
|
|
49
|
-
int _nc = repl_cap == 0 ? 16 : repl_cap * 2; \
|
|
50
|
-
repl_t *_t = (repl_t *)realloc(repl_log, sizeof(repl_t) * _nc); \
|
|
51
|
-
if (!_t) { free(repl_log); free(working); rb_raise(rb_eNoMemError, "repl_log"); } \
|
|
52
|
-
repl_log = _t; repl_cap = _nc; \
|
|
53
|
-
} \
|
|
54
|
-
repl_log[repl_count].wpos = (_wpos); \
|
|
55
|
-
repl_log[repl_count].orig_len = (_olen); \
|
|
56
|
-
repl_count++; \
|
|
57
|
-
} while (0)
|
|
58
|
-
|
|
59
|
-
#define WORKING_TO_ORIG(_wpos) ({ \
|
|
60
|
-
long _shift = 0; \
|
|
61
|
-
for (int _ri = 0; _ri < repl_count; _ri++) { \
|
|
62
|
-
if (repl_log[_ri].wpos <= (_wpos)) \
|
|
63
|
-
_shift += 10 - repl_log[_ri].orig_len; \
|
|
64
|
-
} \
|
|
65
|
-
(_wpos) - _shift; \
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
#define COLLECT_AND_REPLACE(pat, use_bnd, tag_bit, pat_name) do { \
|
|
69
|
-
const char *_cur = working; \
|
|
70
|
-
regmatch_t _m[4]; \
|
|
71
|
-
while (regexec((pat), _cur, 4, _m, 0) == 0) { \
|
|
72
|
-
regoff_t _fso = _m[0].rm_so, _feo = _m[0].rm_eo; \
|
|
73
|
-
if (_fso < 0 || _feo < _fso) break; \
|
|
74
|
-
regoff_t _cso = _fso, _ceo = _feo; \
|
|
75
|
-
if (use_bnd) { \
|
|
76
|
-
if (_m[1].rm_so >= 0 && _m[1].rm_eo > _m[1].rm_so) \
|
|
77
|
-
_cso = _m[1].rm_eo; \
|
|
78
|
-
if (_m[3].rm_so >= 0 && _m[3].rm_eo > _m[3].rm_so) \
|
|
79
|
-
_ceo = _m[3].rm_so; \
|
|
80
|
-
} \
|
|
81
|
-
size_t _vlen = (size_t)(_ceo - _cso); \
|
|
82
|
-
long _wpos = (long)(_cur - working) + (long)_cso; \
|
|
83
|
-
long _orig = WORKING_TO_ORIG(_wpos); \
|
|
84
|
-
VALUE _match = rb_hash_new(); \
|
|
85
|
-
rb_hash_aset(_match, ID2SYM(rb_intern("tag")), \
|
|
86
|
-
ID2SYM(rb_intern(tag_name_for_bit(tag_bit)))); \
|
|
87
|
-
rb_hash_aset(_match, ID2SYM(rb_intern("name")), \
|
|
88
|
-
rb_str_new_cstr(pat_name)); \
|
|
89
|
-
rb_hash_aset(_match, ID2SYM(rb_intern("value")), \
|
|
90
|
-
rb_str_new(_cur + _cso, _vlen)); \
|
|
91
|
-
rb_hash_aset(_match, ID2SYM(rb_intern("start")), \
|
|
92
|
-
LONG2NUM(_orig)); \
|
|
93
|
-
rb_hash_aset(_match, ID2SYM(rb_intern("length")), \
|
|
94
|
-
LONG2NUM((long)_vlen)); \
|
|
95
|
-
rb_ary_push(matches_arr, _match); \
|
|
96
|
-
REPL_LOG_PUSH(_wpos, (long)_vlen); \
|
|
97
|
-
if (_feo == _fso) { if (*_cur) _cur++; else break; } \
|
|
98
|
-
else _cur += _feo; \
|
|
99
|
-
} \
|
|
100
|
-
char *_next = replace_all_matches((pat), working, (use_bnd), &ph_default); \
|
|
101
|
-
free(working); \
|
|
102
|
-
if (!_next) { free(repl_log); rb_raise(rb_eNoMemError, "replace_all_matches failed in scan"); } \
|
|
103
|
-
working = _next; \
|
|
104
|
-
} while (0)
|
|
108
|
+
/* Build the redacted working buffer (same logic as redact_builtins). */
|
|
109
|
+
size_t ph_len = strlen(ph_plain.str); /* "[REDACTED]" = 10 */
|
|
110
|
+
size_t out_cap = in_len + n_ev * ph_len + 1;
|
|
111
|
+
char *working = (char *)malloc(out_cap);
|
|
112
|
+
if (!working) { free(ev); rb_raise(rb_eNoMemError, "scan working buffer alloc"); }
|
|
105
113
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
size_t out_len = 0, cur = 0;
|
|
115
|
+
for (size_t i = 0; i < n_ev; i++) {
|
|
116
|
+
size_t s = ev[i].start, l = ev[i].length;
|
|
117
|
+
if (s > cur) { memcpy(working + out_len, input + cur, s - cur); out_len += s - cur; }
|
|
118
|
+
memcpy(working + out_len, ph_plain.str, ph_len);
|
|
119
|
+
out_len += ph_len;
|
|
120
|
+
cur = s + l;
|
|
110
121
|
}
|
|
122
|
+
if (cur < in_len) { memcpy(working + out_len, input + cur, in_len - cur); out_len += in_len - cur; }
|
|
123
|
+
working[out_len] = '\0';
|
|
111
124
|
|
|
125
|
+
/* ------------------------------------------------------------------ */
|
|
126
|
+
/* Stage 2: custom patterns via glibc on the rewritten buffer. */
|
|
127
|
+
/* Original coords recovered via working_to_orig() using ev[]. */
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
112
129
|
for (int i = 0; i < custom_count; i++) {
|
|
113
130
|
if (!scan_enable_bit(rb_enable_bits, NUM_PATTERNS + i)) continue;
|
|
114
|
-
COLLECT_AND_REPLACE(&custom_patterns[i].compiled,
|
|
115
|
-
custom_patterns[i].boundary,
|
|
116
|
-
custom_patterns[i].tag, custom_patterns[i].name);
|
|
117
|
-
}
|
|
118
131
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
const char *cur_ptr = working;
|
|
133
|
+
regmatch_t m[4];
|
|
134
|
+
while (regexec(&custom_patterns[i].compiled, cur_ptr, 4, m, 0) == 0) {
|
|
135
|
+
regoff_t fso = m[0].rm_so, feo = m[0].rm_eo;
|
|
136
|
+
if (fso < 0 || feo < fso) break;
|
|
137
|
+
|
|
138
|
+
regoff_t cso = fso, ceo = feo;
|
|
139
|
+
if (custom_patterns[i].boundary) {
|
|
140
|
+
if (m[1].rm_so >= 0 && m[1].rm_eo > m[1].rm_so) cso = m[1].rm_eo;
|
|
141
|
+
if (m[3].rm_so >= 0 && m[3].rm_eo > m[3].rm_so) ceo = m[3].rm_so;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
long wpos_core = (long)(cur_ptr - working) + (long)cso;
|
|
145
|
+
long orig_start = working_to_orig(wpos_core, ev, n_ev, ph_len);
|
|
146
|
+
long core_len = (long)(ceo - cso);
|
|
147
|
+
|
|
148
|
+
VALUE h = rb_hash_new();
|
|
149
|
+
rb_hash_aset(h, ID2SYM(rb_intern("tag")),
|
|
150
|
+
ID2SYM(rb_intern(tag_name_for_bit(custom_patterns[i].tag))));
|
|
151
|
+
rb_hash_aset(h, ID2SYM(rb_intern("name")),
|
|
152
|
+
rb_str_new_cstr(custom_patterns[i].name));
|
|
153
|
+
rb_hash_aset(h, ID2SYM(rb_intern("value")),
|
|
154
|
+
rb_str_new(cur_ptr + cso, (size_t)core_len));
|
|
155
|
+
rb_hash_aset(h, ID2SYM(rb_intern("start")), LONG2NUM(orig_start));
|
|
156
|
+
rb_hash_aset(h, ID2SYM(rb_intern("length")), LONG2NUM(core_len));
|
|
157
|
+
rb_ary_push(matches_arr, h);
|
|
158
|
+
|
|
159
|
+
if (feo == fso) { if (*cur_ptr) cur_ptr++; else break; }
|
|
160
|
+
else cur_ptr += feo;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
char *next = replace_all_matches(&custom_patterns[i].compiled, working,
|
|
164
|
+
custom_patterns[i].boundary, &ph_plain);
|
|
165
|
+
free(working);
|
|
166
|
+
if (!next) { free(ev); rb_raise(rb_eNoMemError, "replace_all_matches failed in scan"); }
|
|
167
|
+
working = next;
|
|
168
|
+
}
|
|
122
169
|
|
|
123
|
-
free(
|
|
170
|
+
free(ev);
|
|
124
171
|
|
|
125
|
-
VALUE result
|
|
172
|
+
VALUE result = rb_hash_new();
|
|
126
173
|
VALUE rb_redacted = rb_str_new_cstr(working);
|
|
127
174
|
free(working);
|
|
175
|
+
rb_funcall(rb_redacted, rb_intern("force_encoding"), 1,
|
|
176
|
+
rb_funcall(rb_text, rb_intern("encoding"), 0));
|
|
128
177
|
rb_hash_aset(result, ID2SYM(rb_intern("redacted")), rb_redacted);
|
|
129
178
|
rb_hash_aset(result, ID2SYM(rb_intern("matches")), matches_arr);
|
|
130
179
|
return result;
|
|
@@ -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
|