prompt-sanitizer 0.1.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 +34 -0
- data/README.md +269 -0
- data/lib/generators/prompt_sanitizer/install_generator.rb +38 -0
- data/lib/generators/prompt_sanitizer/templates/initializer.rb +36 -0
- data/lib/prompt_sanitizer/audit/base.rb +105 -0
- data/lib/prompt_sanitizer/audit/memory_audit_log.rb +86 -0
- data/lib/prompt_sanitizer/engines/ner_engine.rb +279 -0
- data/lib/prompt_sanitizer/engines/regex_engine.rb +216 -0
- data/lib/prompt_sanitizer/engines/secrets_engine.rb +230 -0
- data/lib/prompt_sanitizer/entities.rb +56 -0
- data/lib/prompt_sanitizer/integrations/action_controller.rb +64 -0
- data/lib/prompt_sanitizer/integrations/active_job.rb +79 -0
- data/lib/prompt_sanitizer/integrations/middleware.rb +153 -0
- data/lib/prompt_sanitizer/modes.rb +26 -0
- data/lib/prompt_sanitizer/railtie.rb +44 -0
- data/lib/prompt_sanitizer/result.rb +37 -0
- data/lib/prompt_sanitizer/sanitizer.rb +221 -0
- data/lib/prompt_sanitizer/session.rb +97 -0
- data/lib/prompt_sanitizer/synthetic.rb +152 -0
- data/lib/prompt_sanitizer/vault.rb +88 -0
- data/lib/prompt_sanitizer/version.rb +5 -0
- data/lib/prompt_sanitizer.rb +110 -0
- metadata +131 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Engines
|
|
5
|
+
# NER Engine — Layer 2 of prompt-sanitizer (SMART / FULL mode only).
|
|
6
|
+
#
|
|
7
|
+
# Detects context-dependent PII that regex cannot catch: person names,
|
|
8
|
+
# organisations, locations, and miscellaneous named entities.
|
|
9
|
+
#
|
|
10
|
+
# Two backends are supported:
|
|
11
|
+
#
|
|
12
|
+
# :informers — uses the `informers` gem with Xenova/distilbert-NER or
|
|
13
|
+
# Xenova/bert-base-NER (ONNX int8). F1 92.17. ~25–50 ms.
|
|
14
|
+
# Model auto-downloaded to ~/.cache/huggingface/ on first use.
|
|
15
|
+
# Recommended default.
|
|
16
|
+
#
|
|
17
|
+
# :mitie — uses the `mitie` gem with the MITIE C++ NER model.
|
|
18
|
+
# F1 88.10. ~2 ms. Requires separate model download (~600 MB).
|
|
19
|
+
# Use when sub-5 ms latency is required.
|
|
20
|
+
#
|
|
21
|
+
# Both backends are optional runtime dependencies. When neither gem is
|
|
22
|
+
# installed, NEREngine#available? returns false and #detect returns [].
|
|
23
|
+
# The Sanitizer falls back to FAST mode silently in this case.
|
|
24
|
+
#
|
|
25
|
+
# Thread safety: both ONNX Runtime sessions and MITIE models are immutable
|
|
26
|
+
# after loading — safe to share across Puma threads.
|
|
27
|
+
class NEREngine
|
|
28
|
+
# Maximum number of characters per chunk when splitting long prompts.
|
|
29
|
+
# distilbert / bert-base have a 512 subword-token limit; ~300 words
|
|
30
|
+
# ≈ 1,800 characters is a safe conservative ceiling.
|
|
31
|
+
CHUNK_SIZE = 1_800
|
|
32
|
+
CHUNK_OVERLAP = 200 # overlap between chunks to avoid edge-case misses
|
|
33
|
+
|
|
34
|
+
# BIO tag → EntityType mapping (CoNLL-2003 schema)
|
|
35
|
+
TAG_MAP = {
|
|
36
|
+
"PER" => EntityType::PERSON,
|
|
37
|
+
"ORG" => EntityType::ORGANIZATION,
|
|
38
|
+
"LOC" => EntityType::LOCATION,
|
|
39
|
+
"MISC" => EntityType::MISC,
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# ── Construction ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
# @param backend [Symbol] :informers (default) or :mitie
|
|
45
|
+
# @param model [String] "distilbert" (default) or "bert-base" for
|
|
46
|
+
# the informers backend; path to ner_model.dat
|
|
47
|
+
# for the mitie backend.
|
|
48
|
+
def initialize(backend: :informers, model: "distilbert")
|
|
49
|
+
@backend = backend
|
|
50
|
+
@model = model
|
|
51
|
+
@pipeline = nil
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
_load_backend
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns true when the chosen backend gem is installed and the model
|
|
57
|
+
# is ready to use.
|
|
58
|
+
def available?
|
|
59
|
+
!@pipeline.nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Detect named entities in +text+ and return Array<DetectedEntity>.
|
|
63
|
+
# Returns [] immediately when the backend is unavailable.
|
|
64
|
+
#
|
|
65
|
+
# Long texts are automatically chunked and results are merged.
|
|
66
|
+
def detect(text)
|
|
67
|
+
return [] unless available?
|
|
68
|
+
|
|
69
|
+
safe_text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
70
|
+
return [] if safe_text.strip.empty?
|
|
71
|
+
|
|
72
|
+
if safe_text.length > CHUNK_SIZE
|
|
73
|
+
_detect_chunked(safe_text)
|
|
74
|
+
else
|
|
75
|
+
_detect_single(safe_text)
|
|
76
|
+
end
|
|
77
|
+
rescue => e
|
|
78
|
+
# Never let NER failures break the sanitizer — degrade gracefully.
|
|
79
|
+
warn "[PromptSanitizer] NER error (#{@backend}): #{e.message}"
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ── Private ─────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def _load_backend
|
|
88
|
+
case @backend
|
|
89
|
+
when :informers then _load_informers
|
|
90
|
+
when :mitie then _load_mitie
|
|
91
|
+
else
|
|
92
|
+
raise ConfigurationError, "Unknown NER backend: #{@backend.inspect}. Use :informers or :mitie."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ── informers backend ────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def _load_informers
|
|
99
|
+
require "informers"
|
|
100
|
+
|
|
101
|
+
model_id = case @model
|
|
102
|
+
when "distilbert" then "Xenova/distilbert-NER"
|
|
103
|
+
when "bert-base" then "Xenova/bert-base-NER"
|
|
104
|
+
else @model # allow fully-qualified HuggingFace model IDs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Load once and memoize — thread-safe after initialization.
|
|
108
|
+
@pipeline = Informers.pipeline("ner", model_id, dtype: "int8")
|
|
109
|
+
@backend_type = :informers
|
|
110
|
+
rescue LoadError
|
|
111
|
+
# informers gem not installed — NER silently unavailable.
|
|
112
|
+
@pipeline = nil
|
|
113
|
+
rescue => e
|
|
114
|
+
warn "[PromptSanitizer] Failed to load informers NER model: #{e.message}"
|
|
115
|
+
@pipeline = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── mitie backend ────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _load_mitie
|
|
121
|
+
require "mitie"
|
|
122
|
+
|
|
123
|
+
model_path = @model == "distilbert" || @model == "bert-base" ? nil : @model
|
|
124
|
+
model_path ||= ENV.fetch("MITIE_MODEL_PATH", "ner_model.dat")
|
|
125
|
+
|
|
126
|
+
unless File.exist?(model_path)
|
|
127
|
+
warn "[PromptSanitizer] MITIE model not found at #{model_path}. " \
|
|
128
|
+
"Download from https://github.com/mit-nlp/MITIE/releases"
|
|
129
|
+
@pipeline = nil
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@pipeline = Mitie::NER.new(model_path)
|
|
134
|
+
@backend_type = :mitie
|
|
135
|
+
rescue LoadError
|
|
136
|
+
@pipeline = nil
|
|
137
|
+
rescue => e
|
|
138
|
+
warn "[PromptSanitizer] Failed to load MITIE NER model: #{e.message}"
|
|
139
|
+
@pipeline = nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ── Detection ────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def _detect_single(text)
|
|
145
|
+
case @backend_type
|
|
146
|
+
when :informers then _informers_detect(text, offset: 0)
|
|
147
|
+
when :mitie then _mitie_detect(text, offset: 0)
|
|
148
|
+
else []
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Split long text into overlapping chunks, detect in each, then merge.
|
|
153
|
+
# Entities whose start_pos falls in the overlap zone of a later chunk
|
|
154
|
+
# are deduplicated by (original, start_pos) pair.
|
|
155
|
+
def _detect_chunked(text)
|
|
156
|
+
entities = []
|
|
157
|
+
seen = {}
|
|
158
|
+
pos = 0
|
|
159
|
+
|
|
160
|
+
while pos < text.length
|
|
161
|
+
chunk = text[pos, CHUNK_SIZE]
|
|
162
|
+
chunk_hits = _detect_single(chunk).map do |e|
|
|
163
|
+
DetectedEntity.new(
|
|
164
|
+
entity_type: e.entity_type,
|
|
165
|
+
original: e.original,
|
|
166
|
+
replacement: nil,
|
|
167
|
+
start_pos: e.start_pos + pos,
|
|
168
|
+
end_pos: e.end_pos + pos,
|
|
169
|
+
confidence: e.confidence,
|
|
170
|
+
layer: :ner
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
chunk_hits.each do |e|
|
|
175
|
+
key = "#{e.original}:#{e.start_pos}"
|
|
176
|
+
next if seen[key]
|
|
177
|
+
|
|
178
|
+
seen[key] = true
|
|
179
|
+
entities << e
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
pos += CHUNK_SIZE - CHUNK_OVERLAP
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
entities
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# ── informers result parsing ─────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
# informers returns BIO-tagged word pieces; merge consecutive I- tags
|
|
191
|
+
# back into a single span.
|
|
192
|
+
def _informers_detect(text, offset:)
|
|
193
|
+
raw = @pipeline.(text)
|
|
194
|
+
return [] if raw.nil? || raw.empty?
|
|
195
|
+
|
|
196
|
+
entities = []
|
|
197
|
+
current = nil
|
|
198
|
+
|
|
199
|
+
Array(raw).each do |token|
|
|
200
|
+
tag_raw = token[:entity] || token["entity"] || ""
|
|
201
|
+
word = token[:word] || token["word"] || ""
|
|
202
|
+
score = (token[:score] || token["score"] || 0.0).to_f
|
|
203
|
+
t_start = (token[:start] || token["start"] || 0).to_i
|
|
204
|
+
t_end = (token[:end] || token["end"] || 0).to_i
|
|
205
|
+
|
|
206
|
+
# Parse BIO prefix and base tag
|
|
207
|
+
bio, tag = tag_raw.split("-", 2)
|
|
208
|
+
entity_type = TAG_MAP[tag]
|
|
209
|
+
next if entity_type.nil?
|
|
210
|
+
|
|
211
|
+
if bio == "B" || (bio == "I" && current.nil?)
|
|
212
|
+
# Flush previous entity
|
|
213
|
+
entities << _build_entity(current, text) if current
|
|
214
|
+
current = { type: entity_type, start: t_start, end: t_end, score: score, tokens: [word] }
|
|
215
|
+
|
|
216
|
+
elsif bio == "I" && current && current[:type] == entity_type
|
|
217
|
+
# Continue current entity — extend span
|
|
218
|
+
current[:end] = t_end
|
|
219
|
+
current[:score] = [current[:score], score].min # conservative: use lowest
|
|
220
|
+
current[:tokens] << word
|
|
221
|
+
|
|
222
|
+
else
|
|
223
|
+
# Tag changed mid-sequence — flush and start fresh
|
|
224
|
+
entities << _build_entity(current, text) if current
|
|
225
|
+
current = nil
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
entities << _build_entity(current, text) if current
|
|
230
|
+
entities.compact
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def _build_entity(data, text)
|
|
234
|
+
return nil if data.nil?
|
|
235
|
+
|
|
236
|
+
raw_value = text[data[:start]...data[:end]]
|
|
237
|
+
return nil if raw_value.nil? || raw_value.strip.empty?
|
|
238
|
+
|
|
239
|
+
DetectedEntity.new(
|
|
240
|
+
entity_type: data[:type],
|
|
241
|
+
original: raw_value.strip,
|
|
242
|
+
replacement: nil,
|
|
243
|
+
start_pos: data[:start],
|
|
244
|
+
end_pos: data[:end],
|
|
245
|
+
confidence: data[:score],
|
|
246
|
+
layer: :ner
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ── MITIE result parsing ─────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def _mitie_detect(text, offset:)
|
|
253
|
+
doc = @pipeline.doc(text)
|
|
254
|
+
doc.entities.filter_map do |entity|
|
|
255
|
+
tag = entity[:tag]&.upcase
|
|
256
|
+
entity_type = TAG_MAP[tag]
|
|
257
|
+
next unless entity_type
|
|
258
|
+
|
|
259
|
+
value = entity[:text]
|
|
260
|
+
next if value.nil? || value.strip.empty?
|
|
261
|
+
|
|
262
|
+
# MITIE provides character offset via :offset
|
|
263
|
+
char_start = entity[:offset] || text.index(value) || 0
|
|
264
|
+
char_end = char_start + value.length
|
|
265
|
+
|
|
266
|
+
DetectedEntity.new(
|
|
267
|
+
entity_type: entity_type,
|
|
268
|
+
original: value.strip,
|
|
269
|
+
replacement: nil,
|
|
270
|
+
start_pos: char_start,
|
|
271
|
+
end_pos: char_end,
|
|
272
|
+
confidence: (entity[:score] || 0.80).to_f.clamp(0.0, 1.0),
|
|
273
|
+
layer: :ner
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Engines
|
|
5
|
+
# Regex Engine — Layer 1 of prompt-sanitizer.
|
|
6
|
+
#
|
|
7
|
+
# Detects structured PII (email, phone, SSN, credit cards, IBANs, IPs,
|
|
8
|
+
# crypto addresses, MAC addresses, URLs, passport numbers, driving licences,
|
|
9
|
+
# and date patterns) using regular expressions with optional checksum
|
|
10
|
+
# validation (Luhn for credit cards, IBAN mod-97).
|
|
11
|
+
#
|
|
12
|
+
# All patterns run on every sanitize() call regardless of Mode.
|
|
13
|
+
class RegexEngine
|
|
14
|
+
Pattern = Struct.new(:entity_type, :regex, :confidence, :validator, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# ── Validators ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# Luhn algorithm — validates credit/debit card numbers.
|
|
19
|
+
def self.luhn_valid?(card)
|
|
20
|
+
digits = card.gsub(/\D/, "").chars.map(&:to_i)
|
|
21
|
+
return false if digits.size < 13
|
|
22
|
+
|
|
23
|
+
total = digits.reverse.each_with_index.sum do |d, i|
|
|
24
|
+
i.odd? ? [d * 2 - 9, d * 2].min + (d * 2 >= 10 ? 0 : 0) : d
|
|
25
|
+
# Standard Luhn: double every second digit from the right
|
|
26
|
+
if i.odd?
|
|
27
|
+
doubled = d * 2
|
|
28
|
+
doubled > 9 ? doubled - 9 : doubled
|
|
29
|
+
else
|
|
30
|
+
d
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
total % 10 == 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# IBAN mod-97 validation.
|
|
37
|
+
def self.iban_valid?(iban)
|
|
38
|
+
raw = iban.gsub(/[\s\-]/, "").upcase
|
|
39
|
+
return false unless raw.length.between?(15, 34)
|
|
40
|
+
|
|
41
|
+
rearranged = raw[4..] + raw[0..3]
|
|
42
|
+
numeric = rearranged.chars.map { |c| c =~ /[A-Z]/ ? (c.ord - 55).to_s : c }.join
|
|
43
|
+
numeric.to_i % 97 == 1
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ── Pattern registry ─────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
PATTERNS = [
|
|
51
|
+
# ── Email ──────────────────────────────────────────────────────────────
|
|
52
|
+
Pattern.new(
|
|
53
|
+
entity_type: EntityType::EMAIL,
|
|
54
|
+
regex: /(?<![a-zA-Z0-9._%+\-])[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}(?![a-zA-Z0-9._%+\-@])/i,
|
|
55
|
+
confidence: 0.99
|
|
56
|
+
),
|
|
57
|
+
|
|
58
|
+
# ── US phone ───────────────────────────────────────────────────────────
|
|
59
|
+
Pattern.new(
|
|
60
|
+
entity_type: EntityType::PHONE,
|
|
61
|
+
regex: /(?<!\d)(?:\+?1[\s.\-]?)?(?:\([2-9]\d{2}\)|[2-9]\d{2})[\s.\-]?\d{3}[\s.\-]?\d{4}(?!\d)/,
|
|
62
|
+
confidence: 0.85
|
|
63
|
+
),
|
|
64
|
+
|
|
65
|
+
# ── International phone — compact E.164 e.g. +447946123456 ────────────
|
|
66
|
+
Pattern.new(
|
|
67
|
+
entity_type: EntityType::PHONE,
|
|
68
|
+
regex: /(?<!\d)\+[1-9]\d{6,14}(?!\d)/,
|
|
69
|
+
confidence: 0.80
|
|
70
|
+
),
|
|
71
|
+
|
|
72
|
+
# ── International phone — spaced/dashed e.g. +44 20 7946 0958 ─────────
|
|
73
|
+
Pattern.new(
|
|
74
|
+
entity_type: EntityType::PHONE,
|
|
75
|
+
regex: /(?<!\d)\+[1-9]\d{0,3}(?:[\s.\-]\d{2,4}){2,4}(?!\d)/,
|
|
76
|
+
confidence: 0.78
|
|
77
|
+
),
|
|
78
|
+
|
|
79
|
+
# ── US SSN ─────────────────────────────────────────────────────────────
|
|
80
|
+
Pattern.new(
|
|
81
|
+
entity_type: EntityType::SSN,
|
|
82
|
+
regex: /(?<!\d)(?!000|666|9\d{2})\d{3}[\s\-](?!00)\d{2}[\s\-](?!0000)\d{4}(?!\d)/,
|
|
83
|
+
confidence: 0.95
|
|
84
|
+
),
|
|
85
|
+
|
|
86
|
+
# ── Credit / debit card (Luhn-validated) ──────────────────────────────
|
|
87
|
+
Pattern.new(
|
|
88
|
+
entity_type: EntityType::CREDIT_CARD,
|
|
89
|
+
regex: /(?<!\d)(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|3(?:0[0-5]|[68][0-9])[0-9]|6(?:011|5[0-9]{2})|(?:2131|1800|35\d{3}))[\s\-]?(?:\d{4}[\s\-]?){2}\d{1,4}(?!\d)/,
|
|
90
|
+
confidence: 0.95,
|
|
91
|
+
validator: ->(m) { luhn_valid?(m) }
|
|
92
|
+
),
|
|
93
|
+
|
|
94
|
+
# ── IBAN (mod-97 validated) ────────────────────────────────────────────
|
|
95
|
+
Pattern.new(
|
|
96
|
+
entity_type: EntityType::IBAN,
|
|
97
|
+
regex: /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){2,7}\s?[A-Z0-9]{1,4}\b/i,
|
|
98
|
+
confidence: 0.92,
|
|
99
|
+
validator: ->(m) { iban_valid?(m) }
|
|
100
|
+
),
|
|
101
|
+
|
|
102
|
+
# ── IPv4 ───────────────────────────────────────────────────────────────
|
|
103
|
+
Pattern.new(
|
|
104
|
+
entity_type: EntityType::IP_ADDRESS,
|
|
105
|
+
regex: /(?<!\d)(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?!\d)/,
|
|
106
|
+
confidence: 0.90
|
|
107
|
+
),
|
|
108
|
+
|
|
109
|
+
# ── IPv6 (full and compressed forms) ──────────────────────────────────
|
|
110
|
+
Pattern.new(
|
|
111
|
+
entity_type: EntityType::IP_ADDRESS,
|
|
112
|
+
regex: /(?<![:\w])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}|[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4})(?![:\w])/i,
|
|
113
|
+
confidence: 0.90
|
|
114
|
+
),
|
|
115
|
+
|
|
116
|
+
# ── MAC address ────────────────────────────────────────────────────────
|
|
117
|
+
Pattern.new(
|
|
118
|
+
entity_type: EntityType::MAC_ADDRESS,
|
|
119
|
+
regex: /(?<![:\w])(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}(?![:\w])/i,
|
|
120
|
+
confidence: 0.90
|
|
121
|
+
),
|
|
122
|
+
|
|
123
|
+
# ── URL (http/https) ───────────────────────────────────────────────────
|
|
124
|
+
Pattern.new(
|
|
125
|
+
entity_type: EntityType::URL,
|
|
126
|
+
regex: /https?:\/\/(?:[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=%]|(?:%[0-9a-fA-F]{2}))+/i,
|
|
127
|
+
confidence: 0.85
|
|
128
|
+
),
|
|
129
|
+
|
|
130
|
+
# ── Bitcoin address (P2PKH, P2SH, Bech32) ─────────────────────────────
|
|
131
|
+
Pattern.new(
|
|
132
|
+
entity_type: EntityType::CRYPTO_ADDRESS,
|
|
133
|
+
regex: /(?<![a-zA-Z0-9])(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,87})(?![a-zA-Z0-9])/,
|
|
134
|
+
confidence: 0.88
|
|
135
|
+
),
|
|
136
|
+
|
|
137
|
+
# ── Ethereum address ───────────────────────────────────────────────────
|
|
138
|
+
Pattern.new(
|
|
139
|
+
entity_type: EntityType::CRYPTO_ADDRESS,
|
|
140
|
+
regex: /(?<![a-fA-F0-9])0x[0-9a-fA-F]{40}(?![0-9a-fA-F])/,
|
|
141
|
+
confidence: 0.92
|
|
142
|
+
),
|
|
143
|
+
|
|
144
|
+
# ── US Passport ────────────────────────────────────────────────────────
|
|
145
|
+
Pattern.new(
|
|
146
|
+
entity_type: EntityType::PASSPORT,
|
|
147
|
+
regex: /(?<![A-Z0-9])[A-Z]{1,2}\d{7,9}(?![A-Z0-9])/,
|
|
148
|
+
confidence: 0.72
|
|
149
|
+
),
|
|
150
|
+
|
|
151
|
+
# ── US ZIP code ────────────────────────────────────────────────────────
|
|
152
|
+
Pattern.new(
|
|
153
|
+
entity_type: EntityType::ZIP_CODE,
|
|
154
|
+
regex: /(?<!\d)\d{5}(?:-\d{4})?(?!\d)/,
|
|
155
|
+
confidence: 0.55
|
|
156
|
+
),
|
|
157
|
+
|
|
158
|
+
# ── Date patterns (DD/MM/YYYY, YYYY-MM-DD, Month DD YYYY, etc.) ────────
|
|
159
|
+
Pattern.new(
|
|
160
|
+
entity_type: EntityType::DATE,
|
|
161
|
+
regex: /(?<!\d)(?:\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}|\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4})(?!\d)/i,
|
|
162
|
+
confidence: 0.75
|
|
163
|
+
),
|
|
164
|
+
].freeze
|
|
165
|
+
|
|
166
|
+
# ── Instance ─────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def initialize
|
|
169
|
+
@patterns = PATTERNS.dup
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Register a custom regex pattern at runtime.
|
|
173
|
+
#
|
|
174
|
+
# engine.add_pattern(:custom, /\bACME-\d{6}\b/, confidence: 0.90)
|
|
175
|
+
# engine.add_pattern(:custom, "ACME-\\d{6}", confidence: 0.90)
|
|
176
|
+
def add_pattern(entity_type, regex, confidence: 0.80, validator: nil)
|
|
177
|
+
regex = Regexp.new(regex) if regex.is_a?(String)
|
|
178
|
+
@patterns << Pattern.new(
|
|
179
|
+
entity_type: entity_type,
|
|
180
|
+
regex: regex,
|
|
181
|
+
confidence: confidence,
|
|
182
|
+
validator: validator
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Run all patterns against +text+ and return an Array of DetectedEntity.
|
|
187
|
+
# Overlapping matches from different patterns are kept — deduplication
|
|
188
|
+
# happens in the Sanitizer, which has the full multi-engine view.
|
|
189
|
+
def detect(text)
|
|
190
|
+
safe_text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
191
|
+
entities = []
|
|
192
|
+
|
|
193
|
+
@patterns.each do |pat|
|
|
194
|
+
safe_text.scan(pat.regex) do
|
|
195
|
+
m = Regexp.last_match
|
|
196
|
+
value = m[0]
|
|
197
|
+
|
|
198
|
+
next if pat.validator && !self.class.instance_exec(value, &pat.validator)
|
|
199
|
+
|
|
200
|
+
entities << DetectedEntity.new(
|
|
201
|
+
entity_type: pat.entity_type,
|
|
202
|
+
original: value,
|
|
203
|
+
replacement: nil,
|
|
204
|
+
start_pos: m.begin(0),
|
|
205
|
+
end_pos: m.end(0),
|
|
206
|
+
confidence: pat.confidence,
|
|
207
|
+
layer: :regex
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
entities
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|