├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── ip_anonymizer.gemspec ├── lib ├── ip_anonymizer.rb └── ip_anonymizer │ ├── hash_ip.rb │ ├── mask_ip.rb │ └── version.rb └── test ├── ip_anonymizer_test.rb └── test_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: 3.4 11 | bundler-cache: true 12 | - run: bundle exec rake test 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.0 (2024-06-24) 2 | 3 | - Dropped support for Ruby < 3.1 4 | 5 | ## 0.2.0 (2022-10-09) 6 | 7 | - Dropped support for Ruby < 2.7 8 | 9 | ## 0.1.1 (2018-05-11) 10 | 11 | - Better performance when IP not needed 12 | 13 | ## 0.1.0 (2018-05-05) 14 | 15 | - First release 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "benchmark-ips" 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2024 Andrew Kane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IP Anonymizer 2 | 3 | :earth_americas: IP address anonymizer for Ruby and Rails 4 | 5 | Works with IPv4 and IPv6 6 | 7 | Designed to help with [GDPR](https://en.wikipedia.org/wiki/General_Data_Protection_Regulation) compliance 8 | 9 | [![Build Status](https://github.com/ankane/ip_anonymizer/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/ip_anonymizer/actions) 10 | 11 | ## Getting Started 12 | 13 | Add these lines to your application’s Gemfile: 14 | 15 | ```ruby 16 | gem "ip_anonymizer" 17 | ``` 18 | 19 | There are two strategies for anonymizing IPs. 20 | 21 | ### Masking 22 | 23 | This is the approach [Google Analytics uses for IP anonymization](https://support.google.com/analytics/answer/2763052): 24 | 25 | - For IPv4, set the last octet to 0 26 | - For IPv6, set the last 80 bits to zeros 27 | 28 | ```ruby 29 | IpAnonymizer.mask_ip("8.8.4.4") 30 | # => "8.8.4.0" 31 | 32 | IpAnonymizer.mask_ip("2001:4860:4860:0:0:0:0:8844") 33 | # => "2001:4860:4860::" 34 | ``` 35 | 36 | An advantange of this approach is geocoding will still work, only with slightly less accuracy. A potential disadvantage is different IPs will have the same mask (`8.8.4.4` and `8.8.4.5` both become `8.8.4.0`). 37 | 38 | ### Hashing 39 | 40 | Transform IP addresses with a keyed hash function (PBKDF2-HMAC-SHA256). 41 | 42 | ```ruby 43 | IpAnonymizer.hash_ip("8.8.4.4", key: "secret") 44 | # => "6.128.151.207" 45 | 46 | IpAnonymizer.hash_ip("2001:4860:4860:0:0:0:0:8844", key: "secret") 47 | # => "f6e4:a4fe:32dc:2f39:3e47:84cc:e85e:865c" 48 | ``` 49 | 50 | An advantage of this approach is different IPs will have different hashes (with the exception of collisions). 51 | 52 | Make sure the key is kept secret and at least 30 random characters. Otherwise, a rainbow table can be constructed. You can generate a good key with: 53 | 54 | ```ruby 55 | SecureRandom.hex(32) 56 | ``` 57 | 58 | ## Rails 59 | 60 | Automatically anonymize `request.remote_ip` in Rails. 61 | 62 | For masking, add to `config/application.rb`: 63 | 64 | ```ruby 65 | config.middleware.insert_after ActionDispatch::RemoteIp, IpAnonymizer::MaskIp 66 | ``` 67 | 68 | For hashing, use: 69 | 70 | ```ruby 71 | config.middleware.insert_after ActionDispatch::RemoteIp, IpAnonymizer::HashIp, key: "secret" 72 | ``` 73 | 74 | ## Related Projects 75 | 76 | - [Logstop](https://github.com/ankane/logstop) - Keep personally identifiable information (PII) out of your logs 77 | 78 | ## History 79 | 80 | View the [changelog](https://github.com/ankane/ip_anonymizer/blob/master/CHANGELOG.md) 81 | 82 | ## Contributing 83 | 84 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 85 | 86 | - [Report bugs](https://github.com/ankane/ip_anonymizer/issues) 87 | - Fix bugs and [submit pull requests](https://github.com/ankane/ip_anonymizer/pulls) 88 | - Write, clarify, or fix documentation 89 | - Suggest or add new features 90 | 91 | To get started with development: 92 | 93 | ```sh 94 | git clone https://github.com/ankane/ip_anonymizer.git 95 | cd ip_anonymizer 96 | bundle install 97 | bundle exec rake test 98 | ``` 99 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "benchmark/ips" 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << "test" 7 | t.libs << "lib" 8 | t.test_files = FileList["test/**/*_test.rb"] 9 | end 10 | 11 | task default: :test 12 | 13 | task :benchmark do 14 | require "ip_anonymizer" 15 | Benchmark.ips do |x| 16 | x.report("mask_ip") { IpAnonymizer.mask_ip("8.8.4.4") } 17 | x.report("hash_ip") { IpAnonymizer.hash_ip("8.8.4.4", key: "secret") } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /ip_anonymizer.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/ip_anonymizer/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "ip_anonymizer" 5 | spec.version = IpAnonymizer::VERSION 6 | spec.summary = "IP address anonymizer for Ruby and Rails" 7 | spec.homepage = "https://github.com/ankane/ip_anonymizer" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.1" 17 | end 18 | -------------------------------------------------------------------------------- /lib/ip_anonymizer.rb: -------------------------------------------------------------------------------- 1 | # stdlib 2 | require "ipaddr" 3 | require "openssl" 4 | 5 | # modules 6 | require_relative "ip_anonymizer/hash_ip" 7 | require_relative "ip_anonymizer/mask_ip" 8 | require_relative "ip_anonymizer/version" 9 | 10 | module IpAnonymizer 11 | def self.mask_ip(ip) 12 | addr = IPAddr.new(ip.to_s) 13 | if addr.ipv4? 14 | # set last octet to 0 15 | addr.mask(24).to_s 16 | else 17 | # set last 80 bits to zeros 18 | addr.mask(48).to_s 19 | end 20 | end 21 | 22 | def self.hash_ip(ip, key:, iterations: 1) 23 | addr = IPAddr.new(ip.to_s) 24 | key_len = addr.ipv4? ? 4 : 16 25 | family = addr.ipv4? ? Socket::AF_INET : Socket::AF_INET6 26 | 27 | keyed_hash = OpenSSL::KDF.pbkdf2_hmac(addr.to_s, salt: key, iterations: iterations, length: key_len, hash: "sha256") 28 | IPAddr.new(keyed_hash.bytes.inject { |a, b| (a << 8) + b }, family).to_s 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ip_anonymizer/hash_ip.rb: -------------------------------------------------------------------------------- 1 | module IpAnonymizer 2 | class HashIp 3 | def initialize(app, key:) 4 | @app = app 5 | @key = key 6 | end 7 | 8 | def call(env) 9 | req = ActionDispatch::Request.new(env) 10 | # get header directly to preserve ActionDispatch::RemoteIp lazy loading 11 | req.remote_ip = GetIp.new(req.get_header("action_dispatch.remote_ip".freeze), @key) 12 | @app.call(req.env) 13 | end 14 | 15 | class GetIp 16 | def initialize(remote_ip, key) 17 | @remote_ip = remote_ip 18 | @key = key 19 | end 20 | 21 | def to_s 22 | @ip ||= IpAnonymizer.hash_ip(@remote_ip, key: @key) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/ip_anonymizer/mask_ip.rb: -------------------------------------------------------------------------------- 1 | module IpAnonymizer 2 | class MaskIp 3 | def initialize(app) 4 | @app = app 5 | end 6 | 7 | def call(env) 8 | req = ActionDispatch::Request.new(env) 9 | # get header directly to preserve ActionDispatch::RemoteIp lazy loading 10 | req.remote_ip = GetIp.new(req.get_header("action_dispatch.remote_ip".freeze)) 11 | @app.call(req.env) 12 | end 13 | 14 | class GetIp 15 | def initialize(remote_ip) 16 | @remote_ip = remote_ip 17 | end 18 | 19 | def to_s 20 | @ip ||= IpAnonymizer.mask_ip(@remote_ip) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ip_anonymizer/version.rb: -------------------------------------------------------------------------------- 1 | module IpAnonymizer 2 | VERSION = "0.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/ip_anonymizer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class IpAnonymizerTest < Minitest::Test 4 | def test_mask_ipv4 5 | assert_equal "8.8.4.0", IpAnonymizer.mask_ip("8.8.4.4") 6 | end 7 | 8 | def test_mask_ipv6 9 | assert_equal "2001:4860:4860::", IpAnonymizer.mask_ip("2001:4860:4860:0:0:0:0:8844") 10 | end 11 | 12 | def test_hash_ipv4 13 | assert_equal "6.128.151.207", IpAnonymizer.hash_ip("8.8.4.4", key: "secret") 14 | end 15 | 16 | def test_hash_ipv6 17 | assert_equal "f6e4:a4fe:32dc:2f39:3e47:84cc:e85e:865c", IpAnonymizer.hash_ip("2001:4860:4860:0:0:0:0:8844", key: "secret") 18 | end 19 | 20 | def test_ipaddr_object 21 | assert_equal "8.8.4.0", IpAnonymizer.mask_ip(IPAddr.new("8.8.4.4")) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default) 3 | require "minitest/autorun" 4 | require "minitest/pride" 5 | --------------------------------------------------------------------------------