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.
@@ -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