Permalink
Browse files

Add new filter_sensitive_data configuration option.

Closes #38.
  • Loading branch information...
1 parent acb9c52 commit 1f9fa36e2f1700433801bdda2e1f20a6dc0e3cdd @myronmarston committed Feb 10, 2011
View
@@ -15,6 +15,8 @@
new requests to raise an error. Feature suggested by
[Jamie Cobbett](https://github.com/jamiecobbett).
* Made `:once` the default record mode.
+* Add new `filter_sensitive_data` configuration option. Feature
+ suggested by [Nathaniel Bibler](https://github.com/nbibler).
## 1.6.0 (February 3, 2011)
@@ -0,0 +1,154 @@
+Feature: Filter sensitive data
+
+ The `filter_sensitive_data` configuration option can be used to prevent
+ sensitive data from being written to your cassette files. This may be
+ important if you commit your cassettes files to source controla and do
+ not want your sensitive data exposed. Pass the following arguments to
+ `filter_sensitive_data`:
+
+ - A substitution string. This is the string that will be written to
+ the cassettte file as a placeholder. It should be unique and you
+ may want to wrap it in special characters like `{ }` or `< >`.
+ - A symbol specifying a tag (optional). If a tag is given, the
+ filtering will only be applied to cassettes with the given tag.
+ - A block. The block should return the sensitive text that you want
+ replaced with the substitution string. If your block accepts an
+ argument, the HTTP interaction will be yielded so that you can
+ dynamically specify the sensitive text based on the interaction
+ (see the last scenario for an example of this).
+
+ When the interactions are replayed, the sensitive text will replace the
+ substitution string so that the interaction will be identical to what was
+ originally recorded.
+
+ You can specify as many filterings as you want.
+
+ Scenario: Multiple filterings
+ Given a file named "filtering.rb" with:
+ """
+ require 'vcr_cucumber_helpers'
+
+ if ARGV.include?('--with-server')
+ start_sinatra_app(:port => 7777) do
+ get('/') { "Hello World" }
+ end
+ end
+
+ require 'vcr'
+
+ VCR.config do |c|
+ c.stub_with :fakeweb
+ c.cassette_library_dir = 'cassettes'
+ c.filter_sensitive_data('<GREETING>') { 'Hello' }
+ c.filter_sensitive_data('<LOCATION>') { 'World' }
+ end
+
+ VCR.use_cassette('filtering') do
+ response = Net::HTTP.get_response('localhost', '/', 7777)
+ puts "Response: #{response.body}"
+ end
+ """
+ When I run "ruby filtering.rb --with-server"
+ Then the output should contain "Response: Hello World"
+ And the file "cassettes/filtering.yml" should contain "body: <GREETING> <LOCATION>"
+ And the file "cassettes/filtering.yml" should not contain "Hello"
+ And the file "cassettes/filtering.yml" should not contain "World"
+
+ When I run "ruby filtering.rb"
+ Then the output should contain "Hello World"
+
+ Scenario: Filter tagged cassettes
+ Given a file named "tagged_filtering.rb" with:
+ """
+ require 'vcr_cucumber_helpers'
+
+ if ARGV.include?('--with-server')
+ response_count = 0
+ start_sinatra_app(:port => 7777) do
+ get('/') { "Hello World #{response_count += 1 }" }
+ end
+ end
+
+ require 'vcr'
+
+ VCR.config do |c|
+ c.stub_with :fakeweb
+ c.cassette_library_dir = 'cassettes'
+ c.filter_sensitive_data('<LOCATION>', :my_tag) { 'World' }
+ end
+
+ VCR.use_cassette('tagged', :tag => :my_tag) do
+ response = Net::HTTP.get_response('localhost', '/', 7777)
+ puts "Tagged Response: #{response.body}"
+ end
+
+ VCR.use_cassette('untagged') do
+ response = Net::HTTP.get_response('localhost', '/', 7777)
+ puts "Untagged Response: #{response.body}"
+ end
+ """
+ When I run "ruby tagged_filtering.rb --with-server"
+ Then the output should contain each of the following:
+ | Tagged Response: Hello World 1 |
+ | Untagged Response: Hello World 2 |
+ And the file "cassettes/tagged.yml" should contain "body: Hello <LOCATION> 1"
+ And the file "cassettes/untagged.yml" should contain "body: Hello World 2"
+
+ When I run "ruby tagged_filtering.rb"
+ Then the output should contain each of the following:
+ | Tagged Response: Hello World 1 |
+ | Untagged Response: Hello World 2 |
+
+ Scenario: Filter dynamic data based on yielded HTTP interaction
+ Given a file named "dynamic_filtering.rb" with:
+ """
+ require 'vcr_cucumber_helpers'
+ include_http_adapter_for('net/http')
+
+ if ARGV.include?('--with-server')
+ start_sinatra_app(:port => 7777) do
+ helpers do
+ def request_header_for(header_key_fragment)
+ key = env.keys.find { |k| k =~ /#{header_key_fragment}/i }
+ env[key]
+ end
+ end
+
+ get('/') { "#{request_header_for('username')}/#{request_header_for('password')}" }
+ end
+ end
+
+ require 'vcr'
+
+ USER_PASSWORDS = {
+ 'john.doe' => 'monkey',
+ 'jane.doe' => 'cheetah'
+ }
+
+ VCR.config do |c|
+ c.stub_with :fakeweb
+ c.cassette_library_dir = 'cassettes'
+ c.filter_sensitive_data('<PASSWORD>') do |interaction|
+ USER_PASSWORDS[interaction.request.headers['x-http-username'].first]
+ end
+ end
+
+ VCR.use_cassette('example') do
+ puts "Response: " + response_body_for(
+ :get, 'http://localhost:7777/', nil,
+ 'X-HTTP-USERNAME' => 'john.doe',
+ 'X-HTTP-PASSWORD' => USER_PASSWORDS['john.doe']
+ )
+ end
+ """
+ When I run "ruby dynamic_filtering.rb --with-server"
+ Then the output should contain "john.doe/monkey"
+ And the file "cassettes/example.yml" should contain "body: john.doe/<PASSWORD>"
+ And the file "cassettes/example.yml" should contain a YAML fragment like:
+ """
+ x-http-password:
+ - <PASSWORD>
+ """
+
+ When I run "ruby dynamic_filtering.rb"
+ Then the output should contain "john.doe/monkey"
@@ -95,6 +95,19 @@ def modify_file(file_name, orig_text, new_text)
end
end
+Then /^the file "([^"]*)" should contain:$/ do |file_name, expected_content|
+ check_file_content(file_name, expected_content, true)
+end
+
+Then /^the file "([^"]*)" should contain a YAML fragment like:$/ do |file_name, fragment|
+ if defined?(::Psych)
+ # psych serializes things slightly differently...
+ fragment = fragment.split("\n").map { |s| s.rstrip }.join("\n")
+ end
+
+ check_file_content(file_name, fragment, true)
+end
+
Then /^the cassette "([^"]*)" should have the following response bodies:$/ do |file, table|
interactions = in_current_dir { YAML.load_file(file) }
actual_response_bodies = interactions.map { |i| i.response.body }
View
@@ -4,6 +4,7 @@
module VCR
module Config
include VCR::Hooks
+ include VCR::VariableArgsBlockCaller
extend self
define_hook :before_record
@@ -58,6 +59,16 @@ def allow_http_connections_when_no_cassette?
!!@allow_http_connections_when_no_cassette
end
+ def filter_sensitive_data(placeholder, tag = nil, &block)
+ before_record(tag) do |interaction|
+ interaction.filter!(call_block(block, interaction), placeholder)
+ end
+
+ before_playback(tag) do |interaction|
+ interaction.filter!(placeholder, call_block(block, interaction))
+ end
+ end
+
def uri_should_be_ignored?(uri)
uri = URI.parse(uri) unless uri.respond_to?(:host)
ignored_hosts.include?(uri.host)
@@ -12,5 +12,34 @@ def ignore!
def ignored?
@ignored
end
+
+ def filter!(text, replacement_text)
+ return self if [text, replacement_text].any? { |t| t.to_s.empty? }
+ filter_object!(self, text, replacement_text)
+ end
+
+ private
+
+ def filter_object!(object, text, replacement_text)
+ if object.respond_to?(:gsub)
+ object.gsub!(text, replacement_text) if object.include?(text)
+ elsif Hash === object
+ filter_hash!(object, text, replacement_text)
+ elsif object.respond_to?(:each)
+ # This handles nested arrays and structs
+ object.each { |o| filter_object!(o, text, replacement_text) }
+ end
+
+ object
+ end
+
+ def filter_hash!(hash, text, replacement_text)
+ filter_object!(hash.values, text, replacement_text)
+
+ hash.keys.each do |k|
+ new_key = filter_object!(k.dup, text, replacement_text)
+ hash[new_key] = hash.delete(k) unless k == new_key
+ end
+ end
end
end
@@ -137,4 +137,45 @@ def stub_no_http_stubbing_adapter
described_class.uri_should_be_ignored?(URI("http://example.net/")).should be_false
end
end
+
+ describe '.filter_sensitive_data' do
+ let(:interaction) { mock('interaction') }
+ before(:each) { interaction.stub(:filter!) }
+
+ it 'adds a before_record hook that replaces the string returned by the block with the given string' do
+ described_class.filter_sensitive_data('foo', &lambda { 'bar' })
+ interaction.should_receive(:filter!).with('bar', 'foo')
+ described_class.invoke_hook(:before_record, nil, interaction)
+ end
+
+ it 'adds a before_playback hook that replaces the given string with the string returned by the block' do
+ described_class.filter_sensitive_data('foo', &lambda { 'bar' })
+ interaction.should_receive(:filter!).with('foo', 'bar')
+ described_class.invoke_hook(:before_playback, nil, interaction)
+ end
+
+ it 'tags the before_record hook when given a tag' do
+ described_class.should_receive(:before_record).with(:my_tag)
+ described_class.filter_sensitive_data('foo', :my_tag) { 'bar' }
+ end
+
+ it 'tags the before_playback hook when given a tag' do
+ described_class.should_receive(:before_playback).with(:my_tag)
+ described_class.filter_sensitive_data('foo', :my_tag) { 'bar' }
+ end
+
+ it 'yields the interaction to the block for the before_record hook' do
+ yielded_interaction = nil
+ described_class.filter_sensitive_data('foo', &lambda { |i| yielded_interaction = i; 'bar' })
+ described_class.invoke_hook(:before_record, nil, interaction)
+ yielded_interaction.should equal(interaction)
+ end
+
+ it 'yields the interaction to the block for the before_playback hook' do
+ yielded_interaction = nil
+ described_class.filter_sensitive_data('foo', &lambda { |i| yielded_interaction = i; 'bar' })
+ described_class.invoke_hook(:before_playback, nil, interaction)
+ yielded_interaction.should equal(interaction)
+ end
+ end
end
@@ -20,4 +20,65 @@
should be_ignored
end
end
+
+ describe '#filter!' do
+ let(:response_status) { VCR::ResponseStatus.new(200, "OK foo") }
+ let(:body) { "The body foo this is (foo-Foo)" }
+ let(:headers) do {
+ 'x-http-foo' => ['bar23', '23foo'],
+ 'x-http-bar' => ['foo23', '18']
+ } end
+
+ let(:response) do
+ VCR::Response.new(
+ response_status,
+ headers.dup,
+ body.dup,
+ '1.1'
+ )
+ end
+
+ let(:request) do
+ VCR::Request.new(
+ :get,
+ 'http://example-foo.com:80/foo/',
+ body.dup,
+ headers.dup
+ )
+ end
+
+ let(:interaction) { VCR::HTTPInteraction.new(request, response) }
+
+ subject { interaction.filter!('foo', 'AAA') }
+
+ it 'does nothing when given a blank argument' do
+ expect {
+ interaction.filter!(nil, 'AAA')
+ interaction.filter!('foo', nil)
+ interaction.filter!("", 'AAA')
+ interaction.filter!('foo', "")
+ }.not_to change { interaction }
+ end
+
+ [:request, :response].each do |part|
+ it "replaces the sensitive text in the #{part} header keys and values" do
+ subject.send(part).headers.should == {
+ 'x-http-AAA' => ['bar23', '23AAA'],
+ 'x-http-bar' => ['AAA23', '18']
+ }
+ end
+
+ it "replaces the sensitive text in the #{part} body" do
+ subject.send(part).body.should == "The body AAA this is (AAA-Foo)"
+ end
+ end
+
+ it 'replaces the sensitive text in the response status' do
+ subject.response.status.message.should == 'OK AAA'
+ end
+
+ it 'replaces sensitive text in the request URI' do
+ subject.request.uri.should == 'http://example-AAA.com:80/AAA/'
+ end
+ end
end

0 comments on commit 1f9fa36

Please sign in to comment.