24 Jan, 2010

Published at 11:17PM

Tagged with defensio, programming, site, and spam

This post has 5 comments

How to implement basic Defensio spam protection in Rails

Right off the bat I should say that, like most Ruby applications, there are a ton of different ways this could be achieved. But there didn’t seem to be too many examples floating around, so I thought I would explain how I’m doing it on this site.

For those who don’t know what Defensio is, let me explain. It’s a simple web service that essentially filters any content you give it in order to determine if it’s “spammy” or not. Basically, you give it some content and it spits back some attributes that you can use to base decisions on.

There are 3 key attributes that you’ll find very useful: allow, profanity-match, and spaminess.

  • allow – returns true/false depending on whether or not Defensio thinks this piece of content should be allowed on your site
  • profanity-match – returns true/false based on, well, whether or not the content has profanity in it
  • spaminess – returns a Float value between 0 and 1 indicating how “spammy” the content is (1 would be 100% spam).

Based on these attributes, you can setup some rules to handle the flow of your comments. Here’s essentially what my rules are:

  • Automatically approve the comment and allow it to go through if Defensio sets allow to true, profanity-match to false, and a spaminess value between 0 and 0.10
  • Put the comment in an approval queue if any one of the above things fail, and the spaminess is less than 0.75
  • Automatically reject the comment if the spaminess is greater than 0.75, regardless of what the allow and profanity-match values say

With that said, here’s basically how I have things setup. Firstly…

1
$> sudo gem install defensio

It’s pretty simple to interface with the Defensio API without the gem, but why not use it, right?

In my comment model I have a before_create :check_for_spam callback that hands the comment over to Defensio. I added a DefensioResponse class that I pass the returned attributes to. That’s also where I keep the rules/logic. Here are the noteworthy pieces of my comment model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Comment < ActiveRecord::Base
  has_one :defensio_response
  
  attr_accessor :defensio_signature
  
  before_create :check_for_spam
  after_create  :associate_with_defensio_response
    
  attr_protected :approved
  
protected
  def check_for_spam
    result = self.class.defensio.post_document(self.defensio_attributes)
    status, attributes = result.first, result.last
    
    return false unless status == 200
    
    defensio_response = DefensioResponse.init(attributes)
    self.defensio_signature = defensio_response.signature
    self.approved = defensio_response.approved?
    
    defensio_response.proceed?
  end
  
  def associate_with_defensio_response
    DefensioResponse.find_by_signature(self.defensio_signature).update_attribute(:comment_id, self.id)
  end
  
  def self.defensio
    @@defensio ||= Defensio.new(self.defensio_api_key)
  end
  
  def self.defensio_api_key
    YAML::load(File.open(File.join(Rails.root, 'config', 'defensio.yml')))["api_key"]
  end
  
  def defensio_attributes
    {
      "type" => "comment",
      "platform" => "rubyonrails",
      "content" => self.body,
      "author-email" => self.email,
      "author-name" => self.name,
      "author-url" => self.site
    }
  end
  
public
  def approve!
    self.update_attribute(:approved, true)
    self.class.defensio.put_document(signature, { :allow => true })
  end
  
  def mark_as_spam!
    self.update_attribute(:approved, false)
    self.class.defensio.put_document(signature, { :allow => false })
  end
  
  def signature
    self.defensio_response.signature
  end
end

When you post a document to Defensio, it returns an array of two values. The first is the status code and the second is the actual output of the response (as a hash of attributes).

You can see in the check_for_spam method that I’m setting the self.approved flag based on the logic I have in my defensio_response. Like I said, if a comment looks good according to my rules, then I don’t want to be bothered with approving it and let it pass through.

For the missing pieces, here are the internals of my DefensioResponse class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class DefensioResponse < ActiveRecord::Base
  belongs_to :comment
  
  validates_presence_of :allow, :spaminess, :signature
  
  AUTOMATIC_APPROVAL_RANGE = 0.0..0.1
  AUTOMATIC_DENIAL_VALUE = 0.75
  
private  
  def self.clean!(attrs)
    returning({}) do |cleansed_attributes|
      attrs.each do |key, value|
        cleansed_attributes.merge!(key.to_s.underscore => value)
      end
    end
  end
  
public
  def self.init(attributes)
    self.new(clean!(attributes))
  end
  
  def approved?
    self.allow? && 
      self.no_profanity? && 
        AUTOMATIC_APPROVAL_RANGE.include?(self.spaminess)
  end
  
  def no_profanity?
    !self.profanity_match?
  end
  
  def proceed?
    return false if self.spaminess > AUTOMATIC_DENIAL_VALUE
    self.save!
  end
end

All the clean!(attrs) method does is change the response values, which use dashes (i.e. “profanity-match”) to use underscores (i.e. “profanity_match”), since that’s what ActiveRecord prefers.

Also, as you can see the approved? method only returns true if all 3 of my requirements are true. That’s the only way a comment will automatically be accepted.

In my comment model, the final line in the check_for_spam method is a call to the defensio_response.proceed? method. Remember, in Rails, if you return false from a callback then the entire transaction is canceled. So, in the proceed? method, if the spaminess is simply too high, I’m just returning false and canceling everything (no comment or response get saved). But if it’s good to go, I’ll let the self.save! call determine if things should be committed, because really, I don’t want a comment without a defensio response.

Improving Over Time

From the code above, the only thing the defensio gem provides is the post_document and put_document methods, which reflect a RESTful API. You may understand why you would POST a document, but why would you PUT (read: update) one? It’s simple: to report false positives/negatives.

Defensio uses a learning algorithm, and like anything that learns, you have to teach it so it becomes smarter. If you look in my comment model you’ll notice two methods: approve! and mark_as_spam!. Remember, if a comment falls between 0.1 and 0.75 then it goes into the “needs my approval” queue.

If I approve the comment, it updates the flag in the comments table, but it also tells Defensio that “this is a good comment”. Likewise, if I mark one as spam, it updates the flag and tells Defensio that “this is a bad comment”.

That’s one reason why I’m saving a Defensio response with each comment, so I have the response signature to identify the comment later on. The signature is the only common piece of information between the content on Defensio and the comment on this site.

Results

So far, Defensio has been doing great. I’ve only been using it for a few days, and here’s what it has done so far:

Already has prevented 333 spam comments at 96.07% accuracy? I’ll take that.

Like I said, I didn’t find much in terms of examples of how others approached this, so hopefully someone will find this useful. If you’re having spam trouble, I’d take a close look at Defensio. They have a simple API and an ever-increasing level of accuracy. So far I’m very pleased.

Comments

Carl Mercier Monday, 25 Jan, 2010 Posted at 10:40AM

This is a great article, Ryan!

There are a few things that I suggest you change to get the most out of Defensio, however.

- You should not “Automatically reject the comment if the spaminess is greater than 0.75, regardless of what the allow and profanity-match values say”. We strongly advice against that. Technically, a document over 75% spaminess should be spam, but you should always trust our “allow” value.

- You should be more precise when setting “platform”. “rubyonrails” is too generic. The platform field helps us identify some spam that is targeted towards different blogging platforms. I guess in your case this is a little bit harder since you built your own blogging platform, but this is something we advice people to do in general.

- When we say “profanity” = true, you can do a POST to “profanity-filter” to mask profanity with *. Or you might opt to reject the document altogether as you’re doing now.

- Asynchronous calls to Defensio are faster and more accurate. Therefore, they are strongly encouraged. Providing a callback Url is the best way to implement this.

Thanks!

Ryan Monday, 25 Jan, 2010 Posted at 11:14AM

Carl Mercier -

I’ll definitely take a look at making your suggested changes. I want to make sure I’m getting the most out of Defensio. Once I update the code, I’ll make the tweaks in this post to reflect your suggestions.

Thanks!

Chad Sunday, 04 Jul, 2010 Posted at 11:19PM

useful article. ironically, you have a captcha on the site now… was it because defensio wasn’t good enough? or do you need both?

Ryan Monday, 05 Jul, 2010 Posted at 09:01AM

Chad -

Well, Defensio worked (and works) great for identifying potential spam, the problem was it was “unsure” about too many. At least, more than I wanted to deal with. I was getting about 30 comments a day. Defensio prevented them from going through, but it then required a “yes” or “no” from me to approve or mark as spam. If I let it go for 3-4 days all of a sudden I could have 150 comments that I needed to go through. I added the captcha back in to help weed those out.

Korben Dallas Friday, 28 Jan, 2011 Posted at 10:43AM

Thank you very much for this article ! I think a complete example on the Defensio site would be appropriate. I would add 2 points :

1. Change returning({}) by Hash.new.tap for Ruby 1.9 compliant

2. The following migration which is not in your example :

add_column :comments, :approved, :boolean, :default => false
create_table :defensio_responses, :force => true do |table|
  table.integer :comment_id
  table.boolean :allow, :default => false
  table.float   :spaminess
  table.string  :signature
  table.boolean :profanity_match
  table.string  :classification
  table.string  :status
  table.text  :message
  table.float :api_version
end
Do you have something to say about this post?
Retype the image to the right Spam Hint: Are You Human? Textile Formatting Tips

or

Ryan Heath | Site Management A Ruby on Rails production.

This site is a Formed Function. Formed Function LLC | @formedfunction | Get in Touch