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,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# Represents a single detected PII entity.
|
|
5
|
+
DetectedEntity = Struct.new(
|
|
6
|
+
:entity_type, # Symbol — EntityType constant (e.g. :email)
|
|
7
|
+
:original, # String — the raw PII value found in text
|
|
8
|
+
:replacement, # String — the placeholder token (e.g. "[EMAIL_1]")
|
|
9
|
+
:start_pos, # Integer — character offset in the *original* text
|
|
10
|
+
:end_pos, # Integer — end offset (exclusive) in the *original* text
|
|
11
|
+
:confidence, # Float — detection confidence 0.0..1.0
|
|
12
|
+
:layer, # Symbol — :regex | :secrets | :ner
|
|
13
|
+
keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Returned by Sanitizer#sanitize and Session#anonymize_with_result.
|
|
17
|
+
SanitizeResult = Struct.new(
|
|
18
|
+
:text, # String — sanitized text with PII replaced by tokens
|
|
19
|
+
:original, # String — the original unsanitized input
|
|
20
|
+
:entities, # Array<DetectedEntity> — all entities found
|
|
21
|
+
keyword_init: true
|
|
22
|
+
) do
|
|
23
|
+
# Number of PII entities detected.
|
|
24
|
+
def count = entities.size
|
|
25
|
+
|
|
26
|
+
# True if any PII was found.
|
|
27
|
+
def any? = entities.any?
|
|
28
|
+
|
|
29
|
+
# Entities filtered by type.
|
|
30
|
+
def by_type(type) = entities.select { |e| e.entity_type == type }
|
|
31
|
+
|
|
32
|
+
# Mapping of original values to their replacement tokens.
|
|
33
|
+
def mapping
|
|
34
|
+
entities.each_with_object({}) { |e, h| h[e.original] = e.replacement }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# Core sanitization class — the main public interface.
|
|
5
|
+
#
|
|
6
|
+
# Ties together RegexEngine, SecretsEngine, optional NEREngine,
|
|
7
|
+
# SyntheticEngine, Vault, and AuditLog.
|
|
8
|
+
#
|
|
9
|
+
# Usage (one-shot):
|
|
10
|
+
#
|
|
11
|
+
# s = PromptSanitizer::Sanitizer.new(mode: :fast)
|
|
12
|
+
# result = s.sanitize("Email me at john@acme.com")
|
|
13
|
+
# result.text # => "Email me at [EMAIL_1]"
|
|
14
|
+
# result.any? # => true
|
|
15
|
+
#
|
|
16
|
+
# Usage (multi-turn with session):
|
|
17
|
+
#
|
|
18
|
+
# sess = s.session(session_id: "user-42")
|
|
19
|
+
# clean = sess.anonymize(user_prompt)
|
|
20
|
+
# raw_response = call_llm(clean)
|
|
21
|
+
# final = sess.deanonymize(raw_response)
|
|
22
|
+
class Sanitizer
|
|
23
|
+
# @param mode [Symbol] :fast (default), :smart, :full
|
|
24
|
+
# @param locale [String] Faker locale (e.g. "en", "fr")
|
|
25
|
+
# @param entities [Array<Symbol>, nil] whitelist; nil = all
|
|
26
|
+
# @param on_detect [Symbol] :redact (default), :warn, :block
|
|
27
|
+
# @param audit_log [Audit::Base, nil] custom audit backend
|
|
28
|
+
# @param ner_backend [Symbol] :informers (default) or :mitie
|
|
29
|
+
# @param ner_model [String] NER model variant
|
|
30
|
+
def initialize(
|
|
31
|
+
mode: :fast,
|
|
32
|
+
locale: "en",
|
|
33
|
+
entities: nil,
|
|
34
|
+
on_detect: :redact,
|
|
35
|
+
audit_log: nil,
|
|
36
|
+
ner_backend: :informers,
|
|
37
|
+
ner_model: "distilbert"
|
|
38
|
+
)
|
|
39
|
+
unless Mode.valid?(mode)
|
|
40
|
+
raise ConfigurationError, "Invalid mode: #{mode.inspect}. Use :fast, :smart, or :full"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@mode = mode
|
|
44
|
+
@locale = locale
|
|
45
|
+
@on_detect = on_detect.to_sym
|
|
46
|
+
@allowed = entities ? Array(entities).map(&:to_sym).to_set : nil
|
|
47
|
+
|
|
48
|
+
@regex = Engines::RegexEngine.new
|
|
49
|
+
@secrets = Engines::SecretsEngine.new
|
|
50
|
+
@ner = mode == :fast ? nil : Engines::NEREngine.new(backend: ner_backend, model: ner_model)
|
|
51
|
+
|
|
52
|
+
@synthetic = SyntheticEngine.new(locale: locale)
|
|
53
|
+
|
|
54
|
+
@audit = if audit_log && audit_log != :none
|
|
55
|
+
audit_log
|
|
56
|
+
elsif mode == :full
|
|
57
|
+
Audit::MemoryAuditLog.new
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Symbol] active detection mode
|
|
62
|
+
attr_reader :mode
|
|
63
|
+
|
|
64
|
+
# @return [Audit::Base, nil]
|
|
65
|
+
attr_reader :audit
|
|
66
|
+
|
|
67
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
# Sanitize +text+ in a single-use vault. Returns a SanitizeResult.
|
|
70
|
+
#
|
|
71
|
+
# @param text [String]
|
|
72
|
+
# @param session_id [String, nil] included in audit events
|
|
73
|
+
# @return [SanitizeResult]
|
|
74
|
+
def sanitize(text, session_id: nil)
|
|
75
|
+
_run(text, Vault.new, session_id: session_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sanitize a list of texts, each with its own vault.
|
|
79
|
+
#
|
|
80
|
+
# @param texts [Array<String>]
|
|
81
|
+
# @param session_id [String, nil]
|
|
82
|
+
# @return [Array<SanitizeResult>]
|
|
83
|
+
def sanitize_batch(texts, session_id: nil)
|
|
84
|
+
texts.map { |t| sanitize(t, session_id: session_id) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Create a Session for multi-turn anonymize/deanonymize workflows.
|
|
88
|
+
# The session maintains a shared vault so the same PII always maps
|
|
89
|
+
# to the same token, and LLM responses can be deanonymized.
|
|
90
|
+
#
|
|
91
|
+
# Without a block, returns a Session you manage yourself.
|
|
92
|
+
# With a block, yields the Session and clears the vault after the block.
|
|
93
|
+
#
|
|
94
|
+
# @param session_id [String, nil]
|
|
95
|
+
# @yieldparam sess [Session]
|
|
96
|
+
# @return [Session, Object] the Session (no block) or block return value
|
|
97
|
+
def session(session_id: nil, &block)
|
|
98
|
+
sess = Session.new(self, session_id: session_id)
|
|
99
|
+
return sess unless block_given?
|
|
100
|
+
|
|
101
|
+
sess.use(&block)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Register a custom regex pattern.
|
|
105
|
+
#
|
|
106
|
+
# @param entity_type [Symbol] e.g. :custom or any EntityType
|
|
107
|
+
# @param pattern [String] Ruby regex string
|
|
108
|
+
# @param confidence [Float]
|
|
109
|
+
def add_pattern(entity_type, pattern, confidence: 0.85)
|
|
110
|
+
@regex.add_pattern(entity_type.to_sym, pattern, confidence: confidence)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Convenience: restore vault tokens in +text+ back to originals.
|
|
114
|
+
# Useful when you hold a vault externally.
|
|
115
|
+
#
|
|
116
|
+
# @param text [String]
|
|
117
|
+
# @param vault [Vault]
|
|
118
|
+
# @return [String]
|
|
119
|
+
def restore(text, vault:)
|
|
120
|
+
vault.restore(text)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ── Internal pipeline (called by Session too) ──────────────────────────────
|
|
124
|
+
|
|
125
|
+
# @api private
|
|
126
|
+
def _run(text, vault, session_id: nil) # rubocop:disable Naming/MethodParameterName
|
|
127
|
+
return SanitizeResult.new(text: text, original: text, entities: []) if text.nil? || text.empty?
|
|
128
|
+
|
|
129
|
+
# 1. Collect detections from all active layers
|
|
130
|
+
raw = []
|
|
131
|
+
raw.concat(@regex.detect(text))
|
|
132
|
+
raw.concat(@secrets.detect(text))
|
|
133
|
+
raw.concat(@ner.detect(text)) if @ner
|
|
134
|
+
|
|
135
|
+
# 2. Filter to allowed entity types
|
|
136
|
+
raw = raw.select { |e| @allowed.include?(e.entity_type) } if @allowed
|
|
137
|
+
|
|
138
|
+
# 3. Deduplicate overlapping spans
|
|
139
|
+
entities = _deduplicate(raw)
|
|
140
|
+
|
|
141
|
+
# 4. Handle on_detect modes
|
|
142
|
+
if @on_detect == :block && entities.any?
|
|
143
|
+
raise PIIDetectedError, entities
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if @on_detect == :warn
|
|
147
|
+
return SanitizeResult.new(text: text, original: text, entities: entities)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# on_detect == :redact (default)
|
|
151
|
+
|
|
152
|
+
# 5. Assign replacements — reuse vault entry when same PII seen before
|
|
153
|
+
entities.each do |entity|
|
|
154
|
+
existing = vault.replacement_for(entity.original)
|
|
155
|
+
if existing
|
|
156
|
+
entity.replacement = existing
|
|
157
|
+
else
|
|
158
|
+
replacement = if @mode == :full
|
|
159
|
+
@synthetic.generate(entity.entity_type, entity.original)
|
|
160
|
+
else
|
|
161
|
+
@synthetic.placeholder(entity.entity_type)
|
|
162
|
+
end
|
|
163
|
+
entity.replacement = vault.add(entity.original, replacement)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# 6. Reconstruct text right-to-left to preserve byte offsets
|
|
168
|
+
chars = text.dup
|
|
169
|
+
entities.reverse_each do |entity|
|
|
170
|
+
chars[entity.start_pos...entity.end_pos] = entity.replacement.to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 7. Record audit events
|
|
174
|
+
if @audit
|
|
175
|
+
entities.each do |entity|
|
|
176
|
+
method = entity.replacement.to_s.start_with?("[") ? :placeholder : :synthetic
|
|
177
|
+
@audit.record(
|
|
178
|
+
Audit::AuditEvent.new(
|
|
179
|
+
timestamp: Audit.now_iso,
|
|
180
|
+
entity_type: entity.entity_type,
|
|
181
|
+
confidence: entity.confidence,
|
|
182
|
+
layer: entity.layer,
|
|
183
|
+
redaction_method: method,
|
|
184
|
+
text_hash: Audit.hash_value(entity.original),
|
|
185
|
+
session_id: session_id
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
SanitizeResult.new(text: chars, original: text, entities: entities)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# Remove overlapping DetectedEntity spans.
|
|
197
|
+
# Strategy: highest confidence first (then longest span), greedy non-overlap.
|
|
198
|
+
# Returns entities sorted by start_pos.
|
|
199
|
+
def _deduplicate(entities)
|
|
200
|
+
return entities if entities.empty?
|
|
201
|
+
|
|
202
|
+
ranked = entities.sort_by { |e| [-e.confidence, -(e.end_pos - e.start_pos)] }
|
|
203
|
+
|
|
204
|
+
kept = []
|
|
205
|
+
kept_spans = []
|
|
206
|
+
|
|
207
|
+
ranked.each do |entity|
|
|
208
|
+
overlaps = kept_spans.any? do |s, e|
|
|
209
|
+
!(entity.end_pos <= s || entity.start_pos >= e)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
unless overlaps
|
|
213
|
+
kept << entity
|
|
214
|
+
kept_spans << [entity.start_pos, entity.end_pos]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
kept.sort_by(&:start_pos)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# Maintains a shared Vault across multiple anonymize/deanonymize calls.
|
|
5
|
+
#
|
|
6
|
+
# The same PII value always maps to the same replacement token within a
|
|
7
|
+
# session, and LLM responses can be deanonymized back to the originals.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
#
|
|
11
|
+
# sanitizer = PromptSanitizer::Sanitizer.new(mode: :smart)
|
|
12
|
+
#
|
|
13
|
+
# # Single-use block (vault cleared on exit):
|
|
14
|
+
# sanitizer.session(session_id: "user-42") do |sess|
|
|
15
|
+
# clean = sess.anonymize(user_prompt)
|
|
16
|
+
# response = call_llm(clean)
|
|
17
|
+
# puts sess.deanonymize(response)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# # Long-lived (manage lifecycle yourself):
|
|
21
|
+
# sess = sanitizer.session
|
|
22
|
+
# loop { sess.anonymize(...) ... }
|
|
23
|
+
# sess.reset
|
|
24
|
+
class Session
|
|
25
|
+
attr_reader :session_id, :vault
|
|
26
|
+
|
|
27
|
+
# @param sanitizer [Sanitizer]
|
|
28
|
+
# @param session_id [String, nil]
|
|
29
|
+
def initialize(sanitizer, session_id: nil)
|
|
30
|
+
@sanitizer = sanitizer
|
|
31
|
+
@session_id = session_id
|
|
32
|
+
@vault = Vault.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sanitize +text+ using the session's shared vault.
|
|
36
|
+
# Returns the redacted string.
|
|
37
|
+
#
|
|
38
|
+
# @param text [String]
|
|
39
|
+
# @return [String]
|
|
40
|
+
def anonymize(text)
|
|
41
|
+
@sanitizer._run(text, @vault, session_id: @session_id).text
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Like #anonymize but returns the full SanitizeResult.
|
|
45
|
+
#
|
|
46
|
+
# @param text [String]
|
|
47
|
+
# @return [SanitizeResult]
|
|
48
|
+
def anonymize_with_result(text)
|
|
49
|
+
@sanitizer._run(text, @vault, session_id: @session_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Restore vault tokens in +text+ back to the original PII values.
|
|
53
|
+
# Pass the LLM's response here to get a human-readable output.
|
|
54
|
+
#
|
|
55
|
+
# @param text [String]
|
|
56
|
+
# @return [String]
|
|
57
|
+
def deanonymize(text)
|
|
58
|
+
@vault.restore(text)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Snapshot of original → replacement mapping for this session.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash{String => String}]
|
|
64
|
+
def mapping
|
|
65
|
+
@vault.snapshot
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Number of unique PII values currently stored in the session vault.
|
|
69
|
+
#
|
|
70
|
+
# @return [Integer]
|
|
71
|
+
def size
|
|
72
|
+
@vault.snapshot.size
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Clear the vault, resetting the session for a fresh conversation.
|
|
76
|
+
def reset
|
|
77
|
+
@vault.clear
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Block form — vault is cleared after the block returns.
|
|
81
|
+
#
|
|
82
|
+
# sanitizer.session { |s| s.anonymize(...) }
|
|
83
|
+
#
|
|
84
|
+
# @yieldparam session [Session] self
|
|
85
|
+
# @return the block's return value
|
|
86
|
+
def use
|
|
87
|
+
yield self
|
|
88
|
+
ensure
|
|
89
|
+
reset
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def inspect
|
|
93
|
+
"#<PromptSanitizer::Session id=#{@session_id.inspect} mappings=#{size}>"
|
|
94
|
+
end
|
|
95
|
+
alias to_s inspect
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# Generates realistic fake replacement values per EntityType.
|
|
5
|
+
#
|
|
6
|
+
# When the +faker+ gem is installed, each call produces a
|
|
7
|
+
# contextually appropriate fake (names, emails, IPs, …).
|
|
8
|
+
# Without faker the engine falls back to sequential placeholder
|
|
9
|
+
# tokens: +[EMAIL_1]+, +[PERSON_2]+, etc.
|
|
10
|
+
#
|
|
11
|
+
# Determinism within a session is guaranteed by the Vault: the
|
|
12
|
+
# same original value always receives the same token/fake because
|
|
13
|
+
# Session#anonymize only calls +generate+ once per unique original.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
#
|
|
17
|
+
# engine = SyntheticEngine.new(locale: "en_US")
|
|
18
|
+
# engine.generate(:email, "john@acme.com") # => "xavier@mailnull.net"
|
|
19
|
+
# engine.generate(:person, "Alice Smith") # => "Carlos Rivera"
|
|
20
|
+
class SyntheticEngine
|
|
21
|
+
CHARS_ALPHA_NUM = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
require "faker"
|
|
25
|
+
HAS_FAKER = true
|
|
26
|
+
rescue LoadError
|
|
27
|
+
HAS_FAKER = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param locale [String] BCP-47 locale tag forwarded to Faker (e.g. "en", "fr", "de")
|
|
31
|
+
def initialize(locale: "en")
|
|
32
|
+
@locale = locale
|
|
33
|
+
@counters = Hash.new(0) # entity_type Symbol → Integer
|
|
34
|
+
if HAS_FAKER
|
|
35
|
+
Faker::Config.locale = locale
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns a fake replacement string for +entity_type+.
|
|
40
|
+
#
|
|
41
|
+
# @param entity_type [Symbol] one of the EntityType constants
|
|
42
|
+
# @param _original [String] original text (unused here; determinism via Vault)
|
|
43
|
+
# @return [String]
|
|
44
|
+
def generate(entity_type, _original = "")
|
|
45
|
+
if HAS_FAKER
|
|
46
|
+
faker_value(entity_type)
|
|
47
|
+
else
|
|
48
|
+
placeholder(entity_type)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Force a placeholder token regardless of faker availability.
|
|
53
|
+
# Used by Session when the replacement must survive round-trips.
|
|
54
|
+
def placeholder(entity_type)
|
|
55
|
+
@counters[entity_type] += 1
|
|
56
|
+
"[#{entity_type.to_s.upcase}_#{@counters[entity_type]}]"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reset!
|
|
60
|
+
@counters.clear
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
66
|
+
def faker_value(entity_type) # rubocop:disable Metrics/AbcSize
|
|
67
|
+
case entity_type
|
|
68
|
+
when :person
|
|
69
|
+
Faker::Name.name
|
|
70
|
+
when :email
|
|
71
|
+
Faker::Internet.email
|
|
72
|
+
when :phone
|
|
73
|
+
Faker::PhoneNumber.phone_number
|
|
74
|
+
when :ssn
|
|
75
|
+
"#{rand(100..899).to_s.rjust(3, "0")}-" \
|
|
76
|
+
"#{rand(10..99).to_s.rjust(2, "0")}-" \
|
|
77
|
+
"#{rand(1000..9999)}"
|
|
78
|
+
when :credit_card
|
|
79
|
+
fake_luhn_card
|
|
80
|
+
when :iban
|
|
81
|
+
"GB#{rand(10..99)}MOCK" \
|
|
82
|
+
"#{rand(10_000_000..99_999_999)}" \
|
|
83
|
+
"#{rand(100_000_000_000..999_999_999_999)}"
|
|
84
|
+
when :ip_address
|
|
85
|
+
"#{rand(1..254)}.#{rand(0..255)}.#{rand(0..255)}.#{rand(1..254)}"
|
|
86
|
+
when :mac_address
|
|
87
|
+
Array.new(6) { rand(0..255).to_s(16).rjust(2, "0") }.join(":")
|
|
88
|
+
when :url
|
|
89
|
+
"https://#{Faker::Internet.domain_name}/#{Faker::Internet.slug}"
|
|
90
|
+
when :address
|
|
91
|
+
Faker::Address.full_address.tr("\n", ", ")
|
|
92
|
+
when :zip_code
|
|
93
|
+
Faker::Address.postcode
|
|
94
|
+
when :date
|
|
95
|
+
Faker::Date.between(from: "1990-01-01", to: "2020-12-31").strftime("%m/%d/%Y")
|
|
96
|
+
when :date_of_birth
|
|
97
|
+
Faker::Date.birthday(min_age: 18, max_age: 80).strftime("%m/%d/%Y")
|
|
98
|
+
when :crypto_address
|
|
99
|
+
"0x" + Array.new(40) { rand(0..15).to_s(16) }.join
|
|
100
|
+
when :passport
|
|
101
|
+
"#{("A".."Z").to_a.sample}#{rand(10_000_000..99_999_999)}"
|
|
102
|
+
when :driving_license
|
|
103
|
+
"#{("A".."Z").to_a.sample}#{rand(100_000..999_999)}"
|
|
104
|
+
when :organization
|
|
105
|
+
Faker::Company.name
|
|
106
|
+
when :location
|
|
107
|
+
Faker::Address.city
|
|
108
|
+
when :api_key
|
|
109
|
+
"sk-" + Array.new(48) { CHARS_ALPHA_NUM.sample }.join
|
|
110
|
+
when :jwt, :jwt_token
|
|
111
|
+
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJSRURBQ1RFRCJ9.REDACTED_SIGNATURE"
|
|
112
|
+
when :bearer_token, :oauth_token
|
|
113
|
+
"REDACTED_" + Array.new(16) { (("A".."Z").to_a + ("0".."9").to_a).sample }.join
|
|
114
|
+
when :aws_access_key
|
|
115
|
+
"AKIA" + Array.new(16) { (("A".."Z").to_a + ("0".."9").to_a).sample }.join
|
|
116
|
+
when :aws_secret_key
|
|
117
|
+
Array.new(40) { (("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a + ["+", "/"]).sample }.join
|
|
118
|
+
when :private_key
|
|
119
|
+
"-----BEGIN PRIVATE KEY-----\nREDACTED\n-----END PRIVATE KEY-----"
|
|
120
|
+
when :db_connection, :database_url
|
|
121
|
+
"postgresql://user:password@localhost:5432/#{Faker::Lorem.word}"
|
|
122
|
+
when :secret_key, :password
|
|
123
|
+
placeholder(entity_type)
|
|
124
|
+
else
|
|
125
|
+
placeholder(entity_type)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
129
|
+
|
|
130
|
+
# Generates a Luhn-valid 16-digit fake Visa card number.
|
|
131
|
+
def fake_luhn_card
|
|
132
|
+
digits = [4] + Array.new(14) { rand(0..9) }
|
|
133
|
+
|
|
134
|
+
# Calculate check digit — double digits at even indices (0,2,4,...,14)
|
|
135
|
+
# so that in the final 16-digit number they sit at even positions from
|
|
136
|
+
# the right (2,4,...,16) as required by the Luhn algorithm.
|
|
137
|
+
sum = digits.each_with_index.sum do |d, i|
|
|
138
|
+
if i.even?
|
|
139
|
+
doubled = d * 2
|
|
140
|
+
doubled > 9 ? doubled - 9 : doubled
|
|
141
|
+
else
|
|
142
|
+
d
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
check = (10 - (sum % 10)) % 10
|
|
146
|
+
digits << check
|
|
147
|
+
|
|
148
|
+
raw = digits.join
|
|
149
|
+
"#{raw[0, 4]} #{raw[4, 4]} #{raw[8, 4]} #{raw[12, 4]}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# Thread-safe bidirectional in-memory store for a sanitization session.
|
|
5
|
+
#
|
|
6
|
+
# Maps original PII values → their replacement tokens and vice-versa.
|
|
7
|
+
# Deterministic within a session: the same original always maps to the
|
|
8
|
+
# same replacement. The vault is never persisted — it lives only in memory.
|
|
9
|
+
#
|
|
10
|
+
# Safe for use across Puma threads — all reads and writes are guarded by
|
|
11
|
+
# a Mutex.
|
|
12
|
+
class Vault
|
|
13
|
+
def initialize
|
|
14
|
+
@forward = {} # original → replacement
|
|
15
|
+
@reverse = {} # replacement → original
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# ── Write ───────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
# Store an original → replacement mapping.
|
|
22
|
+
# If the original is already mapped, the existing replacement is returned
|
|
23
|
+
# (determinism guarantee). Returns the active replacement string.
|
|
24
|
+
def add(original, replacement)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
unless @forward.key?(original)
|
|
27
|
+
@forward[original] = replacement
|
|
28
|
+
@reverse[replacement] = original
|
|
29
|
+
end
|
|
30
|
+
@forward[original]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ── Read ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def replacement_for(original)
|
|
37
|
+
@forward[original]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def original_for(replacement)
|
|
41
|
+
@reverse[replacement]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ── Restore ─────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
# Replace all known replacement tokens in +text+ with their originals.
|
|
47
|
+
# Tokens are substituted longest-first to avoid partial matches
|
|
48
|
+
# (e.g. "[EMAIL_1]" before "[EMAIL]").
|
|
49
|
+
def restore(text)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
result = text.dup
|
|
52
|
+
@reverse
|
|
53
|
+
.keys
|
|
54
|
+
.sort_by { |k| -k.length }
|
|
55
|
+
.each { |token| result.gsub!(token, @reverse[token]) }
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ── Lifecycle ───────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def clear
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@forward.clear
|
|
65
|
+
@reverse.clear
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ── Introspection ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def size = @forward.size
|
|
72
|
+
alias length size
|
|
73
|
+
|
|
74
|
+
def empty? = @forward.empty?
|
|
75
|
+
|
|
76
|
+
def include?(original) = @forward.key?(original)
|
|
77
|
+
|
|
78
|
+
# Returns a frozen copy of the forward mapping (original → replacement).
|
|
79
|
+
# Safe to call from any thread.
|
|
80
|
+
def snapshot
|
|
81
|
+
@mutex.synchronize { @forward.dup.freeze }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inspect
|
|
85
|
+
"#<#{self.class.name} size=#{size}>"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|