pii_scrubber 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/LICENSE.txt +21 -0
- data/README.md +236 -0
- data/Rakefile +8 -0
- data/exe/pii_scrubber +48 -0
- data/lib/pii_scrubber/configuration.rb +76 -0
- data/lib/pii_scrubber/detectors/api_key.rb +48 -0
- data/lib/pii_scrubber/detectors/base.rb +56 -0
- data/lib/pii_scrubber/detectors/canadian_sin.rb +58 -0
- data/lib/pii_scrubber/detectors/credit_card.rb +58 -0
- data/lib/pii_scrubber/detectors/database_url.rb +40 -0
- data/lib/pii_scrubber/detectors/email.rb +33 -0
- data/lib/pii_scrubber/detectors/iban.rb +60 -0
- data/lib/pii_scrubber/detectors/indian_aadhaar.rb +77 -0
- data/lib/pii_scrubber/detectors/indian_pan.rb +42 -0
- data/lib/pii_scrubber/detectors/ip_address.rb +36 -0
- data/lib/pii_scrubber/detectors/phone.rb +26 -0
- data/lib/pii_scrubber/detectors/ssn.rb +29 -0
- data/lib/pii_scrubber/detectors/uk_nino.rb +49 -0
- data/lib/pii_scrubber/integrations/faraday_middleware.rb +48 -0
- data/lib/pii_scrubber/integrations/logger_formatter.rb +41 -0
- data/lib/pii_scrubber/integrations/rack_middleware.rb +37 -0
- data/lib/pii_scrubber/scrubber.rb +141 -0
- data/lib/pii_scrubber/vault/session.rb +73 -0
- data/lib/pii_scrubber/vault.rb +70 -0
- data/lib/pii_scrubber/version.rb +5 -0
- data/lib/pii_scrubber.rb +50 -0
- metadata +142 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class Iban < Base
|
|
8
|
+
# Matches international bank account numbers (15-34 alphanumeric chars with optional spacing/hyphens)
|
|
9
|
+
IBAN_REGEX = /\b[A-Za-z]{2}\d{2}(?:[ -]?[A-Za-z0-9]{4}){2,7}(?:[ -]?[A-Za-z0-9]{1,4})?\b/
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super(name: :iban)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def patterns
|
|
16
|
+
[IBAN_REGEX]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def valid?(match)
|
|
20
|
+
sanitized = match.gsub(/[\s-]/, "").upcase
|
|
21
|
+
return false unless sanitized.length.between?(15, 34)
|
|
22
|
+
return false unless sanitized.match?(/\A[A-Z]{2}\d{2}[A-Z0-9]+\z/)
|
|
23
|
+
|
|
24
|
+
modulo97_valid?(sanitized)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def replace(match, strategy: :placeholder, mask_char: "*", hmac_salt: nil)
|
|
28
|
+
return super unless strategy == :mask
|
|
29
|
+
|
|
30
|
+
sanitized = match.gsub(/[\s-]/, "").upcase
|
|
31
|
+
return super if sanitized.length < 8
|
|
32
|
+
|
|
33
|
+
prefix = sanitized[0..3] # Country code + check digits
|
|
34
|
+
suffix = sanitized[-4..]
|
|
35
|
+
masked_middle = mask_char * (sanitized.length - 8)
|
|
36
|
+
"#{prefix}#{masked_middle}#{suffix}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# ISO 7064 Modulo 97-10 checksum validation
|
|
42
|
+
def modulo97_valid?(iban)
|
|
43
|
+
# Move first 4 characters (country + check digits) to the end
|
|
44
|
+
rearranged = iban[4..] + iban[0..3]
|
|
45
|
+
|
|
46
|
+
# Convert letters to digits: A=10, B=11, ..., Z=35
|
|
47
|
+
num_str = rearranged.each_char.map do |ch|
|
|
48
|
+
if ch >= "A" && ch <= "Z"
|
|
49
|
+
(ch.ord - 55).to_s
|
|
50
|
+
else
|
|
51
|
+
ch
|
|
52
|
+
end
|
|
53
|
+
end.join
|
|
54
|
+
|
|
55
|
+
# Calculate remainder modulo 97
|
|
56
|
+
num_str.to_i % 97 == 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class IndianAadhaar < Base
|
|
8
|
+
# Matches 12-digit Indian Aadhaar numbers starting with 2-9
|
|
9
|
+
AADHAAR_REGEX = /\b[2-9][0-9]{3}[ -]?[0-9]{4}[ -]?[0-9]{4}\b/
|
|
10
|
+
|
|
11
|
+
# Verhoeff algorithm multiplication table
|
|
12
|
+
D_TABLE = [
|
|
13
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
14
|
+
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
|
15
|
+
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
|
16
|
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
|
17
|
+
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
|
18
|
+
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
|
19
|
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
|
20
|
+
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
|
21
|
+
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
|
22
|
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# Verhoeff algorithm permutation table
|
|
26
|
+
P_TABLE = [
|
|
27
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
28
|
+
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
|
29
|
+
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
|
30
|
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
|
31
|
+
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
|
32
|
+
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
|
33
|
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
|
34
|
+
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8]
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
super(name: :indian_aadhaar)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def patterns
|
|
42
|
+
[AADHAAR_REGEX]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid?(match)
|
|
46
|
+
digits = match.scan(/\d/).map(&:to_i)
|
|
47
|
+
return false unless digits.size == 12
|
|
48
|
+
|
|
49
|
+
# First digit cannot be 0 or 1
|
|
50
|
+
return false if [0, 1].include?(digits.first)
|
|
51
|
+
|
|
52
|
+
verhoeff_valid?(digits)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def replace(match, strategy: :placeholder, mask_char: "*", hmac_salt: nil)
|
|
56
|
+
return super unless strategy == :mask
|
|
57
|
+
|
|
58
|
+
digits = match.scan(/\d/)
|
|
59
|
+
return super if digits.size != 12
|
|
60
|
+
|
|
61
|
+
last_four = digits.last(4).join
|
|
62
|
+
"#{mask_char * 4}-#{mask_char * 4}-#{last_four}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Validates 12-digit number using Verhoeff checksum algorithm
|
|
68
|
+
def verhoeff_valid?(digits)
|
|
69
|
+
checksum = 0
|
|
70
|
+
digits.reverse.each_with_index do |digit, index|
|
|
71
|
+
checksum = D_TABLE[checksum][P_TABLE[index % 8][digit]]
|
|
72
|
+
end
|
|
73
|
+
checksum.zero?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class IndianPan < Base
|
|
8
|
+
# Matches 10-character Indian Permanent Account Numbers (e.g. ABCDE1234F)
|
|
9
|
+
PAN_REGEX = /\b[A-Za-z]{5}[0-9]{4}[A-Za-z]\b/
|
|
10
|
+
|
|
11
|
+
VALID_HOLDER_TYPES = %w[C P H F A T B L J G].freeze
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super(name: :indian_pan)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def patterns
|
|
18
|
+
[PAN_REGEX]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def valid?(match)
|
|
22
|
+
pan = match.upcase
|
|
23
|
+
return false unless pan.length == 10
|
|
24
|
+
|
|
25
|
+
holder_type = pan[3]
|
|
26
|
+
VALID_HOLDER_TYPES.include?(holder_type)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def replace(match, strategy: :placeholder, mask_char: "*", hmac_salt: nil)
|
|
30
|
+
return super unless strategy == :mask
|
|
31
|
+
|
|
32
|
+
pan = match.upcase
|
|
33
|
+
return super if pan.length != 10
|
|
34
|
+
|
|
35
|
+
prefix = pan[0..2]
|
|
36
|
+
digits = pan[5..8]
|
|
37
|
+
suffix = pan[9]
|
|
38
|
+
"#{prefix}#{mask_char * 2}#{digits}#{suffix}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class IpAddress < Base
|
|
8
|
+
IPV4_REGEX = /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/
|
|
9
|
+
IPV6_REGEX = /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super(name: :ip_address)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def patterns
|
|
16
|
+
[IPV4_REGEX, IPV6_REGEX]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def valid?(match)
|
|
20
|
+
# Optional check: skip localhost (127.0.0.1 or ::1) if needed, otherwise valid IP
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def replace(match, strategy: :placeholder, mask_char: "*", hmac_salt: nil)
|
|
25
|
+
return super unless strategy == :mask
|
|
26
|
+
|
|
27
|
+
if match.include?(".") # IPv4
|
|
28
|
+
octets = match.split(".")
|
|
29
|
+
"#{octets[0]}.#{octets[1]}.#{mask_char * 3}.#{mask_char * 3}"
|
|
30
|
+
else
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class Phone < Base
|
|
8
|
+
# Matches international and common domestic phone number formats
|
|
9
|
+
PHONE_REGEX = /(?:\+|\b)(?:[0-9]{1,3}[ -]?)?\(?[0-9]{3}\)?[ -]?[0-9]{3}[ -]?[0-9]{4}\b/
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super(name: :phone)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def patterns
|
|
16
|
+
[PHONE_REGEX]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def valid?(match)
|
|
20
|
+
# Ensure it has between 10 and 15 digits
|
|
21
|
+
digits = match.gsub(/\D/, "")
|
|
22
|
+
digits.length >= 10 && digits.length <= 15
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class Ssn < Base
|
|
8
|
+
SSN_REGEX = /\b(?!000|666|9\d{2})\d{3}[ -]?(?!00)\d{2}[ -]?(?!0000)\d{4}\b/
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super(name: :ssn)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def patterns
|
|
15
|
+
[SSN_REGEX]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replace(match, strategy: :placeholder, mask_char: "*", hmac_salt: nil)
|
|
19
|
+
return super unless strategy == :mask
|
|
20
|
+
|
|
21
|
+
digits = match.scan(/\d/)
|
|
22
|
+
return super if digits.size < 4
|
|
23
|
+
|
|
24
|
+
last_four = digits.last(4).join
|
|
25
|
+
"***-**-#{last_four}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Detectors
|
|
7
|
+
class UkNino < Base
|
|
8
|
+
# Matches UK National Insurance numbers (e.g., QQ123456A, QQ 12 34 56 A, QQ-12-34-56-A)
|
|
9
|
+
UK_NINO_REGEX = /\b[A-Za-z]{2}[ -]?[0-9]{2}[ -]?[0-9]{2}[ -]?[0-9]{2}[ -]?[A-Da-d]\b/
|
|
10
|
+
|
|
11
|
+
DISALLOWED_PREFIXES = %w[GB BG NK KN TN NT ZZ].freeze
|
|
12
|
+
DISALLOWED_CHARS = %w[D F I U V].freeze
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
super(name: :uk_nino)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def patterns
|
|
19
|
+
[UK_NINO_REGEX]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def valid?(match)
|
|
23
|
+
sanitized = match.gsub(/[\s-]/, "").upcase
|
|
24
|
+
return false unless sanitized.length == 9
|
|
25
|
+
|
|
26
|
+
prefix = sanitized[0..1]
|
|
27
|
+
return false if DISALLOWED_PREFIXES.include?(prefix)
|
|
28
|
+
|
|
29
|
+
# Characters D, F, I, U, V are disallowed in first or second position
|
|
30
|
+
# Character O is disallowed in second position
|
|
31
|
+
return false if DISALLOWED_CHARS.include?(sanitized[0])
|
|
32
|
+
return false if (DISALLOWED_CHARS + ["O"]).include?(sanitized[1])
|
|
33
|
+
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def replace(match, strategy: :placeholder, mask_char: "*", hmac_salt: nil)
|
|
38
|
+
return super unless strategy == :mask
|
|
39
|
+
|
|
40
|
+
sanitized = match.gsub(/[\s-]/, "").upcase
|
|
41
|
+
return super if sanitized.length != 9
|
|
42
|
+
|
|
43
|
+
prefix = sanitized[0..1]
|
|
44
|
+
suffix = sanitized[-1]
|
|
45
|
+
"#{prefix}#{mask_char * 6}#{suffix}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PiiScrubber
|
|
4
|
+
module Integrations
|
|
5
|
+
class FaradayMiddleware
|
|
6
|
+
SENSITIVE_HEADERS = %w[authorization cookie x-api-key x-auth-token set-cookie].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :app, :options
|
|
9
|
+
|
|
10
|
+
def initialize(app, options = {})
|
|
11
|
+
@app = app
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
sanitize_request(env)
|
|
17
|
+
|
|
18
|
+
@app.call(env).on_complete do |response_env|
|
|
19
|
+
sanitize_response(response_env)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def sanitize_request(env)
|
|
26
|
+
if env.request_headers
|
|
27
|
+
SENSITIVE_HEADERS.each do |hdr|
|
|
28
|
+
env.request_headers.keys.each do |key|
|
|
29
|
+
if key.to_s.downcase == hdr
|
|
30
|
+
env.request_headers[key] = PiiScrubber.scrub(env.request_headers[key].to_s, **options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if env.body.is_a?(String) || env.body.is_a?(Hash)
|
|
37
|
+
env.body = PiiScrubber.scrub(env.body, **options)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sanitize_response(env)
|
|
42
|
+
if env.response_body.is_a?(String) || env.response_body.is_a?(Hash)
|
|
43
|
+
env.response_body = PiiScrubber.scrub(env.response_body, **options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
module Integrations
|
|
7
|
+
class LoggerFormatter < ::Logger::Formatter
|
|
8
|
+
attr_reader :original_formatter, :options
|
|
9
|
+
|
|
10
|
+
def initialize(original_formatter = nil, **options)
|
|
11
|
+
super()
|
|
12
|
+
@original_formatter = original_formatter || ::Logger::Formatter.new
|
|
13
|
+
@options = options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(severity, time, program_name, message)
|
|
17
|
+
scrubbed_msg = format_message(message)
|
|
18
|
+
if original_formatter.respond_to?(:call)
|
|
19
|
+
original_formatter.call(severity, time, program_name, scrubbed_msg)
|
|
20
|
+
else
|
|
21
|
+
super(severity, time, program_name, scrubbed_msg)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def format_message(msg)
|
|
28
|
+
case msg
|
|
29
|
+
when String
|
|
30
|
+
PiiScrubber.scrub(msg, **options)
|
|
31
|
+
when Hash, Array
|
|
32
|
+
PiiScrubber.scrub(msg, **options)
|
|
33
|
+
when Exception
|
|
34
|
+
"#{msg.class}: #{PiiScrubber.scrub(msg.message, **options)}"
|
|
35
|
+
else
|
|
36
|
+
msg.inspect
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PiiScrubber
|
|
4
|
+
module Integrations
|
|
5
|
+
class RackMiddleware
|
|
6
|
+
attr_reader :app, :options
|
|
7
|
+
|
|
8
|
+
def initialize(app, options = {})
|
|
9
|
+
@app = app
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
sanitize_rack_env(env)
|
|
15
|
+
@app.call(env)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def sanitize_rack_env(env)
|
|
21
|
+
# Sanitize query string parameters
|
|
22
|
+
if env["QUERY_STRING"] && !env["QUERY_STRING"].empty?
|
|
23
|
+
env["QUERY_STRING"] = PiiScrubber.scrub(env["QUERY_STRING"], **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sanitize Rack request params (ActionDispatch / Rack::Request)
|
|
27
|
+
if env["rack.request.form_hash"].is_a?(Hash)
|
|
28
|
+
env["rack.request.form_hash"] = PiiScrubber.scrub(env["rack.request.form_hash"], **options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if env["action_dispatch.request.parameters"].is_a?(Hash)
|
|
32
|
+
env["action_dispatch.request.parameters"] = PiiScrubber.scrub(env["action_dispatch.request.parameters"], **options)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module PiiScrubber
|
|
6
|
+
class Scrubber
|
|
7
|
+
BUILT_IN_DETECTORS = {
|
|
8
|
+
email: Detectors::Email,
|
|
9
|
+
phone: Detectors::Phone,
|
|
10
|
+
ssn: Detectors::Ssn,
|
|
11
|
+
credit_card: Detectors::CreditCard,
|
|
12
|
+
api_key: Detectors::ApiKey,
|
|
13
|
+
ip_address: Detectors::IpAddress,
|
|
14
|
+
iban: Detectors::Iban,
|
|
15
|
+
uk_nino: Detectors::UkNino,
|
|
16
|
+
canadian_sin: Detectors::CanadianSin,
|
|
17
|
+
indian_pan: Detectors::IndianPan,
|
|
18
|
+
indian_aadhaar: Detectors::IndianAadhaar,
|
|
19
|
+
database_url: Detectors::DatabaseUrl
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :config
|
|
23
|
+
|
|
24
|
+
def initialize(config = PiiScrubber.configuration)
|
|
25
|
+
@config = config
|
|
26
|
+
@active_detectors = build_detectors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def scrub(data, **override_options)
|
|
30
|
+
strategy = override_options[:strategy] || config.strategy
|
|
31
|
+
mask_char = override_options[:mask_char] || config.mask_char
|
|
32
|
+
hmac_salt = override_options[:hmac_salt] || config.hmac_salt
|
|
33
|
+
ignored_keys = (override_options[:ignored_keys] || config.ignored_keys).map(&:to_s)
|
|
34
|
+
|
|
35
|
+
case data
|
|
36
|
+
when String
|
|
37
|
+
scrub_string(data, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt)
|
|
38
|
+
when Hash
|
|
39
|
+
scrub_hash(data, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt, ignored_keys: ignored_keys)
|
|
40
|
+
when Array
|
|
41
|
+
data.map { |item| scrub(item, **override_options) }
|
|
42
|
+
else
|
|
43
|
+
data
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_detectors
|
|
50
|
+
detectors = []
|
|
51
|
+
enabled_names = config.detectors.map(&:to_sym)
|
|
52
|
+
|
|
53
|
+
enabled_names.each do |name|
|
|
54
|
+
detector_class = BUILT_IN_DETECTORS[name]
|
|
55
|
+
detectors << detector_class.new if detector_class
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
config.custom_rules.each do |rule|
|
|
59
|
+
if rule.is_a?(Detectors::Base)
|
|
60
|
+
detectors << rule
|
|
61
|
+
elsif rule.is_a?(Hash) && rule[:name] && rule[:pattern]
|
|
62
|
+
detectors << create_custom_detector(rule[:name], rule[:pattern], rule[:validator])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
detectors
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_custom_detector(name, pattern, validator = nil)
|
|
70
|
+
detector = Detectors::Base.new(name: name)
|
|
71
|
+
pattern_arr = Array(pattern)
|
|
72
|
+
detector.define_singleton_method(:patterns) { pattern_arr }
|
|
73
|
+
if validator
|
|
74
|
+
detector.define_singleton_method(:valid?) { |match| validator.call(match) }
|
|
75
|
+
end
|
|
76
|
+
detector
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def scrub_string(str, strategy:, mask_char:, hmac_salt:)
|
|
80
|
+
return str if str.nil? || str.empty?
|
|
81
|
+
|
|
82
|
+
# If string is valid JSON, attempt JSON-aware scrubbing first
|
|
83
|
+
if json_string?(str)
|
|
84
|
+
begin
|
|
85
|
+
parsed = JSON.parse(str)
|
|
86
|
+
scrubbed = scrub(parsed, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt)
|
|
87
|
+
return JSON.generate(scrubbed)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
# Fallback to normal string replacement if parsing fails
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
result = str.dup
|
|
94
|
+
|
|
95
|
+
@active_detectors.each do |detector|
|
|
96
|
+
detector.patterns.each do |pattern|
|
|
97
|
+
result.gsub!(pattern) do |match|
|
|
98
|
+
if detector.valid?(match)
|
|
99
|
+
detector.replace(match, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt)
|
|
100
|
+
else
|
|
101
|
+
match
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
result
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def scrub_hash(hash, strategy:, mask_char:, hmac_salt:, ignored_keys:)
|
|
111
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
112
|
+
str_key = key.to_s
|
|
113
|
+
if ignored_keys.include?(str_key)
|
|
114
|
+
acc[key] = value
|
|
115
|
+
elsif config.redact_sensitive_hash_keys && sensitive_key?(str_key)
|
|
116
|
+
acc[key] = redact_sensitive_value(value, key_name: str_key, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt)
|
|
117
|
+
else
|
|
118
|
+
acc[key] = scrub(value, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt, ignored_keys: ignored_keys)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def sensitive_key?(key_str)
|
|
124
|
+
config.sensitive_key_patterns.any? { |pattern| key_str.match?(pattern) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def redact_sensitive_value(value, key_name:, strategy:, mask_char:, hmac_salt:)
|
|
128
|
+
detector = Detectors::Base.new(name: key_name)
|
|
129
|
+
if value.is_a?(String)
|
|
130
|
+
detector.replace(value, strategy: strategy, mask_char: mask_char, hmac_salt: hmac_salt)
|
|
131
|
+
else
|
|
132
|
+
"[REDACTED:#{key_name.upcase}]"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def json_string?(str)
|
|
137
|
+
trimmed = str.strip
|
|
138
|
+
(trimmed.start_with?("{") && trimmed.end_with?("}")) || (trimmed.start_with?("[") && trimmed.end_with?("]"))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module PiiScrubber
|
|
7
|
+
class Vault
|
|
8
|
+
class Session
|
|
9
|
+
attr_reader :id, :text, :mappings, :created_at
|
|
10
|
+
|
|
11
|
+
def initialize(text:, mappings:, id: nil, created_at: nil)
|
|
12
|
+
@id = id || SecureRandom.hex(8)
|
|
13
|
+
@text = text
|
|
14
|
+
@mappings = mappings || {}
|
|
15
|
+
@created_at = created_at || Time.now.utc
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Restores original values in text, hashes, or arrays containing vault placeholders
|
|
19
|
+
def restore(data)
|
|
20
|
+
case data
|
|
21
|
+
when String
|
|
22
|
+
restore_string(data)
|
|
23
|
+
when Hash
|
|
24
|
+
restore_hash(data)
|
|
25
|
+
when Array
|
|
26
|
+
data.map { |item| restore(item) }
|
|
27
|
+
else
|
|
28
|
+
data
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Serializes session for storing in Redis, Memcached, or Rails sessions
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
"id" => id,
|
|
36
|
+
"text" => text,
|
|
37
|
+
"mappings" => mappings,
|
|
38
|
+
"created_at" => created_at.iso8601
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Deserializes session from Hash
|
|
43
|
+
def self.from_h(hash)
|
|
44
|
+
return nil if hash.nil?
|
|
45
|
+
|
|
46
|
+
new(
|
|
47
|
+
id: hash["id"] || hash[:id],
|
|
48
|
+
text: hash["text"] || hash[:text],
|
|
49
|
+
mappings: hash["mappings"] || hash[:mappings] || {},
|
|
50
|
+
created_at: hash["created_at"] ? Time.parse(hash["created_at"].to_s) : Time.now.utc
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def restore_string(str)
|
|
57
|
+
return str if str.nil? || str.empty? || mappings.empty?
|
|
58
|
+
|
|
59
|
+
result = str.dup
|
|
60
|
+
mappings.each do |placeholder, original_value|
|
|
61
|
+
result.gsub!(placeholder, original_value.to_s)
|
|
62
|
+
end
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def restore_hash(hash)
|
|
67
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
68
|
+
acc[key] = restore(value)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|