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,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Engines
|
|
5
|
+
# Secrets Engine — Layer 1 of prompt-sanitizer (secrets branch).
|
|
6
|
+
#
|
|
7
|
+
# Detects credentials, API keys, tokens, and connection strings that should
|
|
8
|
+
# never reach an LLM. Runs alongside the RegexEngine on every sanitize() call.
|
|
9
|
+
class SecretsEngine
|
|
10
|
+
Pattern = Struct.new(:entity_type, :regex, :confidence, :label, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
PATTERNS = [
|
|
13
|
+
# ── JWT (header.payload.signature) ────────────────────────────────────
|
|
14
|
+
Pattern.new(
|
|
15
|
+
entity_type: EntityType::JWT,
|
|
16
|
+
regex: /eyJ[a-zA-Z0-9_\-]{10,}\.eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/,
|
|
17
|
+
confidence: 0.99,
|
|
18
|
+
label: "JWT"
|
|
19
|
+
),
|
|
20
|
+
|
|
21
|
+
# ── Bearer token ──────────────────────────────────────────────────────
|
|
22
|
+
Pattern.new(
|
|
23
|
+
entity_type: EntityType::BEARER_TOKEN,
|
|
24
|
+
regex: /(?:Authorization\s*:\s*)?Bearer\s+([a-zA-Z0-9_\-\.]{20,})/i,
|
|
25
|
+
confidence: 0.92,
|
|
26
|
+
label: "Bearer token"
|
|
27
|
+
),
|
|
28
|
+
|
|
29
|
+
# ── AWS Access Key ID ──────────────────────────────────────────────────
|
|
30
|
+
Pattern.new(
|
|
31
|
+
entity_type: EntityType::AWS_ACCESS_KEY,
|
|
32
|
+
regex: /(?<![A-Z0-9])(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/,
|
|
33
|
+
confidence: 0.99,
|
|
34
|
+
label: "AWS access key ID"
|
|
35
|
+
),
|
|
36
|
+
|
|
37
|
+
# ── AWS Secret Access Key (context-anchored) ───────────────────────────
|
|
38
|
+
Pattern.new(
|
|
39
|
+
entity_type: EntityType::AWS_SECRET_KEY,
|
|
40
|
+
regex: /(?:aws_secret(?:_access)?_key|secret_access_key)\s*[=:"'\s]\s*([a-zA-Z0-9+\/]{40})/i,
|
|
41
|
+
confidence: 0.97,
|
|
42
|
+
label: "AWS secret access key"
|
|
43
|
+
),
|
|
44
|
+
|
|
45
|
+
# ── OpenAI API key ─────────────────────────────────────────────────────
|
|
46
|
+
Pattern.new(
|
|
47
|
+
entity_type: EntityType::API_KEY,
|
|
48
|
+
regex: /sk-(?:proj-|org-)?[a-zA-Z0-9_\-T]{20,}/,
|
|
49
|
+
confidence: 0.97,
|
|
50
|
+
label: "OpenAI API key"
|
|
51
|
+
),
|
|
52
|
+
|
|
53
|
+
# ── Anthropic API key ──────────────────────────────────────────────────
|
|
54
|
+
Pattern.new(
|
|
55
|
+
entity_type: EntityType::API_KEY,
|
|
56
|
+
regex: /sk-ant-(?:api\d{2}-)?[a-zA-Z0-9_\-]{20,}/,
|
|
57
|
+
confidence: 0.99,
|
|
58
|
+
label: "Anthropic API key"
|
|
59
|
+
),
|
|
60
|
+
|
|
61
|
+
# ── GitHub Personal Access Token (classic) ─────────────────────────────
|
|
62
|
+
Pattern.new(
|
|
63
|
+
entity_type: EntityType::API_KEY,
|
|
64
|
+
regex: /ghp_[a-zA-Z0-9]{36}/,
|
|
65
|
+
confidence: 0.99,
|
|
66
|
+
label: "GitHub PAT (classic)"
|
|
67
|
+
),
|
|
68
|
+
|
|
69
|
+
# ── GitHub Fine-grained PAT ────────────────────────────────────────────
|
|
70
|
+
Pattern.new(
|
|
71
|
+
entity_type: EntityType::API_KEY,
|
|
72
|
+
regex: /github_pat_[a-zA-Z0-9_]{82}/,
|
|
73
|
+
confidence: 0.99,
|
|
74
|
+
label: "GitHub fine-grained PAT"
|
|
75
|
+
),
|
|
76
|
+
|
|
77
|
+
# ── GitHub OAuth / server-to-server / refresh ──────────────────────────
|
|
78
|
+
Pattern.new(
|
|
79
|
+
entity_type: EntityType::API_KEY,
|
|
80
|
+
regex: /(?:gho|ghs|ghr)_[a-zA-Z0-9]{36}/,
|
|
81
|
+
confidence: 0.99,
|
|
82
|
+
label: "GitHub OAuth/server token"
|
|
83
|
+
),
|
|
84
|
+
|
|
85
|
+
# ── Slack tokens ───────────────────────────────────────────────────────
|
|
86
|
+
Pattern.new(
|
|
87
|
+
entity_type: EntityType::API_KEY,
|
|
88
|
+
regex: /xox[baprs]-(?:[0-9a-zA-Z]{4,}-)+[0-9a-zA-Z]{4,}/,
|
|
89
|
+
confidence: 0.98,
|
|
90
|
+
label: "Slack token"
|
|
91
|
+
),
|
|
92
|
+
|
|
93
|
+
# ── Stripe secret / publishable key ───────────────────────────────────
|
|
94
|
+
Pattern.new(
|
|
95
|
+
entity_type: EntityType::API_KEY,
|
|
96
|
+
regex: /(?:sk|pk)_(?:live|test)_[a-zA-Z0-9]{24,}/,
|
|
97
|
+
confidence: 0.99,
|
|
98
|
+
label: "Stripe API key"
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
# ── Twilio Account SID ─────────────────────────────────────────────────
|
|
102
|
+
Pattern.new(
|
|
103
|
+
entity_type: EntityType::API_KEY,
|
|
104
|
+
regex: /AC[a-f0-9]{32}/,
|
|
105
|
+
confidence: 0.90,
|
|
106
|
+
label: "Twilio Account SID"
|
|
107
|
+
),
|
|
108
|
+
|
|
109
|
+
# ── Twilio Auth Token ──────────────────────────────────────────────────
|
|
110
|
+
Pattern.new(
|
|
111
|
+
entity_type: EntityType::API_KEY,
|
|
112
|
+
regex: /(?:auth_token|TWILIO_AUTH_TOKEN)\s*[=:"'\s]\s*([a-f0-9]{32})/i,
|
|
113
|
+
confidence: 0.97,
|
|
114
|
+
label: "Twilio Auth Token"
|
|
115
|
+
),
|
|
116
|
+
|
|
117
|
+
# ── Google API key ─────────────────────────────────────────────────────
|
|
118
|
+
Pattern.new(
|
|
119
|
+
entity_type: EntityType::API_KEY,
|
|
120
|
+
regex: /AIza[0-9A-Za-z_\-]{35}/,
|
|
121
|
+
confidence: 0.99,
|
|
122
|
+
label: "Google API key"
|
|
123
|
+
),
|
|
124
|
+
|
|
125
|
+
# ── SendGrid API key ───────────────────────────────────────────────────
|
|
126
|
+
Pattern.new(
|
|
127
|
+
entity_type: EntityType::API_KEY,
|
|
128
|
+
regex: /SG\.[a-zA-Z0-9_\-]{22}\.[a-zA-Z0-9_\-]{43}/,
|
|
129
|
+
confidence: 0.99,
|
|
130
|
+
label: "SendGrid API key"
|
|
131
|
+
),
|
|
132
|
+
|
|
133
|
+
# ── Mailchimp API key ──────────────────────────────────────────────────
|
|
134
|
+
Pattern.new(
|
|
135
|
+
entity_type: EntityType::API_KEY,
|
|
136
|
+
regex: /[a-f0-9]{32}-us\d{1,2}/,
|
|
137
|
+
confidence: 0.90,
|
|
138
|
+
label: "Mailchimp API key"
|
|
139
|
+
),
|
|
140
|
+
|
|
141
|
+
# ── HuggingFace token ──────────────────────────────────────────────────
|
|
142
|
+
Pattern.new(
|
|
143
|
+
entity_type: EntityType::API_KEY,
|
|
144
|
+
regex: /hf_[a-zA-Z0-9]{34,}/,
|
|
145
|
+
confidence: 0.99,
|
|
146
|
+
label: "HuggingFace token"
|
|
147
|
+
),
|
|
148
|
+
|
|
149
|
+
# ── PEM private key header ─────────────────────────────────────────────
|
|
150
|
+
Pattern.new(
|
|
151
|
+
entity_type: EntityType::PRIVATE_KEY,
|
|
152
|
+
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----/,
|
|
153
|
+
confidence: 0.99,
|
|
154
|
+
label: "PEM private key"
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
# ── Database connection strings ────────────────────────────────────────
|
|
158
|
+
Pattern.new(
|
|
159
|
+
entity_type: EntityType::DB_CONNECTION,
|
|
160
|
+
regex: /(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|mssql|sqlserver|oracle|clickhouse|cassandra|couchdb|neo4j):\/\/[^\s'"`<>\n]{8,}/i,
|
|
161
|
+
confidence: 0.97,
|
|
162
|
+
label: "Database connection string"
|
|
163
|
+
),
|
|
164
|
+
|
|
165
|
+
# ── Generic secret assignment (SECRET_KEY=..., api_key: '...') ─────────
|
|
166
|
+
Pattern.new(
|
|
167
|
+
entity_type: EntityType::API_KEY,
|
|
168
|
+
regex: /(?:secret[_\-]?key|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret)\s*[=:"'\s]+\s*([a-zA-Z0-9_\-\.+\/]{16,})/i,
|
|
169
|
+
confidence: 0.80,
|
|
170
|
+
label: "Generic secret assignment"
|
|
171
|
+
),
|
|
172
|
+
].freeze
|
|
173
|
+
|
|
174
|
+
# ── Instance ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def initialize
|
|
177
|
+
@patterns = PATTERNS.dup
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Register a custom secret pattern at runtime.
|
|
181
|
+
def add_pattern(entity_type, regex, label: "custom secret", confidence: 0.85)
|
|
182
|
+
@patterns << Pattern.new(
|
|
183
|
+
entity_type: entity_type,
|
|
184
|
+
regex: regex,
|
|
185
|
+
confidence: confidence,
|
|
186
|
+
label: label
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Scan +text+ for secrets and return an Array of DetectedEntity.
|
|
191
|
+
# When the pattern has a capturing group, the captured value and its
|
|
192
|
+
# offsets are used; otherwise the full match is used.
|
|
193
|
+
def detect(text)
|
|
194
|
+
safe_text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
195
|
+
entities = []
|
|
196
|
+
|
|
197
|
+
@patterns.each do |pat|
|
|
198
|
+
safe_text.scan(pat.regex) do
|
|
199
|
+
m = Regexp.last_match
|
|
200
|
+
|
|
201
|
+
# Use capture group 1 when present (strips surrounding context)
|
|
202
|
+
if m.captures.any?
|
|
203
|
+
value = m[1]
|
|
204
|
+
start_pos = m.begin(1)
|
|
205
|
+
end_pos = m.end(1)
|
|
206
|
+
else
|
|
207
|
+
value = m[0]
|
|
208
|
+
start_pos = m.begin(0)
|
|
209
|
+
end_pos = m.end(0)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
next if value.nil? || value.empty?
|
|
213
|
+
|
|
214
|
+
entities << DetectedEntity.new(
|
|
215
|
+
entity_type: pat.entity_type,
|
|
216
|
+
original: value,
|
|
217
|
+
replacement: nil,
|
|
218
|
+
start_pos: start_pos,
|
|
219
|
+
end_pos: end_pos,
|
|
220
|
+
confidence: pat.confidence,
|
|
221
|
+
layer: :secrets
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
entities
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# All PII / sensitive entity types the gem can detect.
|
|
5
|
+
# Values are symbols — mirror the Python/JS string enum names.
|
|
6
|
+
module EntityType
|
|
7
|
+
# ── Personal identifiers ──────────────────────────────────────────────
|
|
8
|
+
PERSON = :person
|
|
9
|
+
EMAIL = :email
|
|
10
|
+
PHONE = :phone
|
|
11
|
+
DATE_OF_BIRTH = :date_of_birth
|
|
12
|
+
AGE = :age
|
|
13
|
+
|
|
14
|
+
# ── Location ──────────────────────────────────────────────────────────
|
|
15
|
+
ADDRESS = :address
|
|
16
|
+
ZIP_CODE = :zip_code
|
|
17
|
+
|
|
18
|
+
# ── Financial ─────────────────────────────────────────────────────────
|
|
19
|
+
CREDIT_CARD = :credit_card
|
|
20
|
+
IBAN = :iban
|
|
21
|
+
BANK_ACCOUNT = :bank_account
|
|
22
|
+
CRYPTO_ADDRESS = :crypto_address
|
|
23
|
+
|
|
24
|
+
# ── Government IDs ────────────────────────────────────────────────────
|
|
25
|
+
SSN = :ssn
|
|
26
|
+
PASSPORT = :passport
|
|
27
|
+
DRIVING_LICENSE = :driving_license
|
|
28
|
+
|
|
29
|
+
# ── Network / Digital ─────────────────────────────────────────────────
|
|
30
|
+
IP_ADDRESS = :ip_address
|
|
31
|
+
MAC_ADDRESS = :mac_address
|
|
32
|
+
URL = :url
|
|
33
|
+
|
|
34
|
+
# ── Secrets & credentials ─────────────────────────────────────────────
|
|
35
|
+
API_KEY = :api_key
|
|
36
|
+
JWT = :jwt
|
|
37
|
+
BEARER_TOKEN = :bearer_token
|
|
38
|
+
AWS_ACCESS_KEY = :aws_access_key
|
|
39
|
+
AWS_SECRET_KEY = :aws_secret_key
|
|
40
|
+
PRIVATE_KEY = :private_key
|
|
41
|
+
DB_CONNECTION = :db_connection
|
|
42
|
+
|
|
43
|
+
# ── Temporal ──────────────────────────────────────────────────────────
|
|
44
|
+
DATE = :date
|
|
45
|
+
|
|
46
|
+
# ── NER-only (detected by SMART / FULL mode) ──────────────────────────
|
|
47
|
+
ORGANIZATION = :organization
|
|
48
|
+
LOCATION = :location
|
|
49
|
+
MISC = :misc
|
|
50
|
+
|
|
51
|
+
# ── User-defined ──────────────────────────────────────────────────────
|
|
52
|
+
CUSTOM = :custom
|
|
53
|
+
|
|
54
|
+
ALL = constants(false).map { |c| const_get(c) }.freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Integrations
|
|
5
|
+
# ActionController concern — adds PII sanitization helpers to controllers.
|
|
6
|
+
#
|
|
7
|
+
# Include in ApplicationController or a specific controller:
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationController < ActionController::Base
|
|
10
|
+
# include PromptSanitizer::Integrations::ActionControllerConcern
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Then in any action:
|
|
14
|
+
#
|
|
15
|
+
# # Sanitize specific params in-place before using them:
|
|
16
|
+
# def create
|
|
17
|
+
# sanitize_params!(:message, :prompt)
|
|
18
|
+
# call_llm(params[:message])
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # Multi-turn session scoped to one action (vault cleared after block):
|
|
22
|
+
# def chat
|
|
23
|
+
# with_pii_session do |sess|
|
|
24
|
+
# clean = sess.anonymize(params[:message])
|
|
25
|
+
# response = call_llm(clean)
|
|
26
|
+
# render json: { reply: sess.deanonymize(response) }
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
module ActionControllerConcern
|
|
30
|
+
# Sanitize one or more param keys in-place.
|
|
31
|
+
#
|
|
32
|
+
# @param keys [Array<Symbol, String>] param keys whose string values
|
|
33
|
+
# should be sanitized. Nested paths not supported (flatten first).
|
|
34
|
+
# @return [void]
|
|
35
|
+
def sanitize_params!(*keys)
|
|
36
|
+
keys.flatten.each do |key|
|
|
37
|
+
next unless params[key].is_a?(String)
|
|
38
|
+
|
|
39
|
+
result = pii_sanitizer.sanitize(params[key])
|
|
40
|
+
params[key] = result.text
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Yield a Session scoped to this action's PII vault.
|
|
45
|
+
# The vault is always cleared after the block, even on exception.
|
|
46
|
+
#
|
|
47
|
+
# @yieldparam session [PromptSanitizer::Session]
|
|
48
|
+
# @return the block's return value
|
|
49
|
+
def with_pii_session(session_id: nil, &block)
|
|
50
|
+
id = session_id || "#{controller_name}##{action_name}"
|
|
51
|
+
pii_sanitizer.session(session_id: id, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The Sanitizer instance used by this controller.
|
|
55
|
+
# Defaults to the global PromptSanitizer.sanitizer.
|
|
56
|
+
# Override in a controller to use a custom instance.
|
|
57
|
+
#
|
|
58
|
+
# @return [PromptSanitizer::Sanitizer]
|
|
59
|
+
def pii_sanitizer
|
|
60
|
+
PromptSanitizer.sanitizer
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Integrations
|
|
5
|
+
# ActiveJob concern — sanitize job arguments before the job is enqueued.
|
|
6
|
+
#
|
|
7
|
+
# Prevents PII from leaking into job queues (Sidekiq, Resque, GoodJob, etc.)
|
|
8
|
+
# where job arguments are serialized and often stored in plain text.
|
|
9
|
+
#
|
|
10
|
+
# == Usage
|
|
11
|
+
#
|
|
12
|
+
# class SummarizeJob < ApplicationJob
|
|
13
|
+
# include PromptSanitizer::Integrations::ActiveJobConcern
|
|
14
|
+
# sanitize_argument :prompt
|
|
15
|
+
#
|
|
16
|
+
# def perform(prompt:)
|
|
17
|
+
# # `prompt` is already sanitized here
|
|
18
|
+
# call_llm(prompt)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# == How it works
|
|
23
|
+
#
|
|
24
|
+
# +sanitize_argument+ registers an +around_perform+ callback that sanitizes
|
|
25
|
+
# the named keyword argument(s) before +perform+ is called and restores them
|
|
26
|
+
# in the job's return context if needed.
|
|
27
|
+
#
|
|
28
|
+
# The original PII never touches the queue backend — the redacted version is
|
|
29
|
+
# enqueued and the job works with sanitized text.
|
|
30
|
+
module ActiveJobConcern
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
included do
|
|
34
|
+
# Class-level list of keyword argument names to sanitize.
|
|
35
|
+
class_attribute :_pii_sanitized_arguments, default: []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class_methods do
|
|
39
|
+
# Declare which keyword arguments should be sanitized before perform.
|
|
40
|
+
#
|
|
41
|
+
# @param args [Array<Symbol>] keyword argument names
|
|
42
|
+
def sanitize_argument(*args)
|
|
43
|
+
self._pii_sanitized_arguments = _pii_sanitized_arguments | args.map(&:to_sym)
|
|
44
|
+
|
|
45
|
+
around_perform do |job, block|
|
|
46
|
+
sanitizer = PromptSanitizer.sanitizer
|
|
47
|
+
session = sanitizer.session(session_id: job.job_id)
|
|
48
|
+
|
|
49
|
+
# Sanitize the declared keyword arguments in job_data / arguments.
|
|
50
|
+
# ActiveJob stores keyword args as the last Hash element in arguments.
|
|
51
|
+
kwargs = job.arguments.last
|
|
52
|
+
if kwargs.is_a?(Hash)
|
|
53
|
+
_pii_sanitized_arguments.each do |key|
|
|
54
|
+
str_key = key.to_s
|
|
55
|
+
if kwargs[key].is_a?(String)
|
|
56
|
+
kwargs[key] = session.anonymize(kwargs[key])
|
|
57
|
+
elsif kwargs[str_key].is_a?(String)
|
|
58
|
+
kwargs[str_key] = session.anonymize(kwargs[str_key])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
block.call
|
|
64
|
+
ensure
|
|
65
|
+
session&.reset
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Manually sanitize a string using the global sanitizer.
|
|
71
|
+
#
|
|
72
|
+
# @param text [String]
|
|
73
|
+
# @return [String] sanitized text
|
|
74
|
+
def sanitize_pii(text)
|
|
75
|
+
PromptSanitizer.sanitizer.sanitize(text).text
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Integrations
|
|
5
|
+
# Rack middleware — auto-sanitizes incoming JSON request bodies.
|
|
6
|
+
#
|
|
7
|
+
# Intercepts POST/PUT/PATCH requests with Content-Type: application/json
|
|
8
|
+
# and sanitizes the following payload keys before they reach the app:
|
|
9
|
+
#
|
|
10
|
+
# - +messages[*].content+ (OpenAI / Anthropic / LangChain style)
|
|
11
|
+
# - +prompt+, +input+, +inputs+, +text+, +query+
|
|
12
|
+
#
|
|
13
|
+
# The sanitized body is written back into the Rack env so that
|
|
14
|
+
# downstream controllers see clean payloads with no raw PII.
|
|
15
|
+
#
|
|
16
|
+
# Optionally, if +restore_response: true+, the middleware deanonymizes
|
|
17
|
+
# JSON response bodies using the same session vault before sending them
|
|
18
|
+
# to the client.
|
|
19
|
+
#
|
|
20
|
+
# == Usage (manually)
|
|
21
|
+
#
|
|
22
|
+
# use PromptSanitizer::Integrations::SanitizerMiddleware,
|
|
23
|
+
# sanitizer: PromptSanitizer.sanitizer,
|
|
24
|
+
# routes: ["/api/llm"],
|
|
25
|
+
# restore_response: false
|
|
26
|
+
#
|
|
27
|
+
# == Usage (via Railtie config)
|
|
28
|
+
#
|
|
29
|
+
# config.prompt_sanitizer.middleware = true
|
|
30
|
+
# config.prompt_sanitizer.middleware_routes = ["/api/llm"]
|
|
31
|
+
class SanitizerMiddleware
|
|
32
|
+
# Keys in a flat JSON body whose string values are sanitized.
|
|
33
|
+
BODY_KEYS = %w[prompt input inputs text query message content].freeze
|
|
34
|
+
|
|
35
|
+
# @param app [#call] next Rack app
|
|
36
|
+
# @param sanitizer [Sanitizer] defaults to PromptSanitizer.sanitizer
|
|
37
|
+
# @param routes [Array<String>, nil] path prefixes to match (nil = all)
|
|
38
|
+
# @param restore_response [Boolean] deanonymize JSON response? (default false)
|
|
39
|
+
def initialize(app, sanitizer: nil, routes: nil, restore_response: false)
|
|
40
|
+
@app = app
|
|
41
|
+
@san = sanitizer || PromptSanitizer.sanitizer
|
|
42
|
+
@routes = routes ? Array(routes) : nil
|
|
43
|
+
@restore = restore_response
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(env)
|
|
47
|
+
req = Rack::Request.new(env)
|
|
48
|
+
|
|
49
|
+
session = nil
|
|
50
|
+
|
|
51
|
+
if should_process?(req)
|
|
52
|
+
session = _sanitize_request(req)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
status, headers, body = @app.call(env)
|
|
56
|
+
|
|
57
|
+
if session && @restore
|
|
58
|
+
status, headers, body = _restore_response(status, headers, body, session)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
[status, headers, body]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def should_process?(req)
|
|
67
|
+
return false unless %w[POST PUT PATCH].include?(req.request_method)
|
|
68
|
+
return false unless req.content_type&.include?("application/json")
|
|
69
|
+
|
|
70
|
+
return true if @routes.nil?
|
|
71
|
+
|
|
72
|
+
@routes.any? { |r| req.path.start_with?(r) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def _sanitize_request(req)
|
|
76
|
+
raw = req.body.read
|
|
77
|
+
req.body.rewind
|
|
78
|
+
return nil if raw.nil? || raw.empty?
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
payload = JSON.parse(raw)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
session = @san.session
|
|
87
|
+
_sanitize_payload!(payload, session)
|
|
88
|
+
|
|
89
|
+
# Write the sanitized body back so downstream sees clean params.
|
|
90
|
+
sanitized_raw = JSON.generate(payload)
|
|
91
|
+
env = req.env
|
|
92
|
+
env["rack.input"] = StringIO.new(sanitized_raw)
|
|
93
|
+
env["CONTENT_LENGTH"] = sanitized_raw.bytesize.to_s
|
|
94
|
+
# Invalidate any already-parsed params cache.
|
|
95
|
+
env.delete("rack.request.form_hash")
|
|
96
|
+
env.delete("rack.request.form_input")
|
|
97
|
+
|
|
98
|
+
session
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def _sanitize_payload!(obj, session)
|
|
104
|
+
case obj
|
|
105
|
+
when Hash
|
|
106
|
+
# messages array (OpenAI / LangChain style)
|
|
107
|
+
if obj["messages"].is_a?(Array)
|
|
108
|
+
obj["messages"].each do |msg|
|
|
109
|
+
next unless msg.is_a?(Hash) && msg["content"].is_a?(String)
|
|
110
|
+
|
|
111
|
+
msg["content"] = session.anonymize(msg["content"])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
BODY_KEYS.each do |key|
|
|
116
|
+
obj[key] = session.anonymize(obj[key]) if obj[key].is_a?(String)
|
|
117
|
+
end
|
|
118
|
+
when Array
|
|
119
|
+
obj.each { |item| _sanitize_payload!(item, session) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def _restore_response(status, headers, body, session)
|
|
124
|
+
content_type = headers["Content-Type"] || headers["content-type"] || ""
|
|
125
|
+
return [status, headers, body] unless content_type.include?("application/json")
|
|
126
|
+
|
|
127
|
+
raw = +""
|
|
128
|
+
body.each { |chunk| raw << chunk }
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
obj = JSON.parse(raw)
|
|
132
|
+
restored = _restore_obj(obj, session)
|
|
133
|
+
new_body = JSON.generate(restored)
|
|
134
|
+
headers["Content-Length"] = new_body.bytesize.to_s
|
|
135
|
+
[status, headers, [new_body]]
|
|
136
|
+
rescue JSON::ParserError
|
|
137
|
+
[status, headers, [raw]]
|
|
138
|
+
end
|
|
139
|
+
ensure
|
|
140
|
+
body.close if body.respond_to?(:close)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def _restore_obj(obj, session)
|
|
144
|
+
case obj
|
|
145
|
+
when String then session.deanonymize(obj)
|
|
146
|
+
when Hash then obj.transform_values { |v| _restore_obj(v, session) }
|
|
147
|
+
when Array then obj.map { |v| _restore_obj(v, session) }
|
|
148
|
+
else obj
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
module Mode
|
|
5
|
+
# Regex + secrets only. Sub-millisecond. Zero dependencies.
|
|
6
|
+
# Catches: email, phone, SSN, credit card, IBAN, IP, crypto, MAC, URL,
|
|
7
|
+
# API keys, JWTs, bearer tokens, AWS keys, DB connection strings,
|
|
8
|
+
# private keys, passport, driving licence, date-of-birth.
|
|
9
|
+
FAST = :fast
|
|
10
|
+
|
|
11
|
+
# FAST + NER (informers/distilbert or mitie). ~25–50 ms on CPU.
|
|
12
|
+
# Additionally catches: names, organisations, locations, misc entities.
|
|
13
|
+
# Requires: gem "informers" (or gem "mitie")
|
|
14
|
+
SMART = :smart
|
|
15
|
+
|
|
16
|
+
# Everything in SMART plus synthetic replacements and audit logging.
|
|
17
|
+
# Requires: gem "informers", gem "faker"
|
|
18
|
+
FULL = :full
|
|
19
|
+
|
|
20
|
+
ALL = [FAST, SMART, FULL].freeze
|
|
21
|
+
|
|
22
|
+
def self.valid?(mode)
|
|
23
|
+
ALL.include?(mode)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptSanitizer
|
|
4
|
+
# Rails::Railtie — hooks prompt-sanitizer into the Rails boot process.
|
|
5
|
+
#
|
|
6
|
+
# Loaded automatically when Rails is present (detected in prompt_sanitizer.rb).
|
|
7
|
+
# Exposes a configuration namespace under +config.prompt_sanitizer+
|
|
8
|
+
# so Rails apps can tune the gem from an initializer without monkey-patching.
|
|
9
|
+
#
|
|
10
|
+
# Generated initializer (from +rails g prompt_sanitizer:install+)::
|
|
11
|
+
#
|
|
12
|
+
# PromptSanitizer.configure do |c|
|
|
13
|
+
# c.mode = :smart
|
|
14
|
+
# c.locale = "en"
|
|
15
|
+
# c.audit_log = :memory
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Middleware (optional)::
|
|
19
|
+
#
|
|
20
|
+
# config.prompt_sanitizer.middleware = true # all routes
|
|
21
|
+
# config.prompt_sanitizer.middleware_routes = ["/api/llm", "/chat"]
|
|
22
|
+
# config.prompt_sanitizer.restore_response = false
|
|
23
|
+
class Railtie < Rails::Railtie
|
|
24
|
+
# Expose a config object on the Rails::Application config.
|
|
25
|
+
config.prompt_sanitizer = ActiveSupport::OrderedOptions.new
|
|
26
|
+
config.prompt_sanitizer.middleware = false
|
|
27
|
+
config.prompt_sanitizer.middleware_routes = nil
|
|
28
|
+
config.prompt_sanitizer.restore_response = false
|
|
29
|
+
|
|
30
|
+
# After all initializers run, insert middleware if requested.
|
|
31
|
+
initializer "prompt_sanitizer.insert_middleware", after: :load_config_initializers do |app|
|
|
32
|
+
cfg = app.config.prompt_sanitizer
|
|
33
|
+
if cfg.middleware
|
|
34
|
+
require_relative "integrations/middleware"
|
|
35
|
+
app.middleware.use(
|
|
36
|
+
PromptSanitizer::Integrations::SanitizerMiddleware,
|
|
37
|
+
sanitizer: PromptSanitizer.sanitizer,
|
|
38
|
+
routes: cfg.middleware_routes,
|
|
39
|
+
restore_response: cfg.restore_response
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|