Skip to content

Commit

Permalink
Add a spam check (mastodon#11217)
Browse files Browse the repository at this point in the history
* Add a spam check

* Use Nilsimsa to generate locality-sensitive hashes and compare using Levenshtein distance

* Add more tests

* Add exemption when the message is a reply to something that mentions the sender

* Use Nilsimsa Compare Value instead of Levenshtein distance

* Use MD5 for messages shorter than 10 characters

* Add message to automated report, do not add non-public statuses to
automated report, add trust level to accounts and make unsilencing
raise the trust level to prevent repeated spam checks on that account

* Expire spam check data after 3 months

* Add support for local statuses, reduce expiration to 1 week, always create a report

* Add content warnings to the spam check and exempt empty statuses

* Change Nilsimsa threshold to 95 and make sure removed statuses are removed from the spam check

* Add all matched statuses into automatic report
  • Loading branch information
Gargron authored and hiyuki2578 committed Oct 2, 2019
1 parent 23946b8 commit 132e91b
Show file tree
Hide file tree
Showing 10 changed files with 377 additions and 5 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.1'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.2', require: 'mime/types/columnar'
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
gem 'nokogiri', '~> 1.10'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.7'
Expand Down
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ GIT
specs:
http_parser.rb (0.6.1)

GIT
remote: https://github.com/witgo/nilsimsa
revision: fd184883048b922b176939f851338d0a4971a532
ref: fd184883048b922b176939f851338d0a4971a532
specs:
nilsimsa (1.1.2)

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -704,6 +711,7 @@ DEPENDENCIES
microformats (~> 4.1)
mime-types (~> 3.2)
net-ldap (~> 0.10)
nilsimsa!
nokogiri (~> 1.10)
nsa (~> 0.2)
oj (~> 3.7)
Expand Down
13 changes: 13 additions & 0 deletions app/lib/activitypub/activity/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def process_status

resolve_thread(@status)
fetch_replies(@status)
check_for_spam
distribute(@status)
forward_for_reply if @status.distributable?
end
Expand Down Expand Up @@ -406,6 +407,18 @@ def addresses_local_accounts?
Account.local.where(username: local_usernames).exists?
end

def check_for_spam
spam_check = SpamCheck.new(@status)

return if spam_check.skip?

if spam_check.spam?
spam_check.flag!
else
spam_check.remember!
end
end

def forward_for_reply
return unless @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
Expand Down
169 changes: 169 additions & 0 deletions app/lib/spam_check.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# frozen_string_literal: true

class SpamCheck
include Redisable
include ActionView::Helpers::TextHelper

NILSIMSA_COMPARE_THRESHOLD = 95
NILSIMSA_MIN_SIZE = 10
EXPIRE_SET_AFTER = 1.week.seconds

def initialize(status)
@account = status.account
@status = status
end

def skip?
already_flagged? || trusted? || no_unsolicited_mentions? || solicited_reply?
end

def spam?
if insufficient_data?
false
elsif nilsimsa?
any_other_digest?('nilsimsa') { |_, other_digest| nilsimsa_compare_value(digest, other_digest) >= NILSIMSA_COMPARE_THRESHOLD }
else
any_other_digest?('md5') { |_, other_digest| other_digest == digest }
end
end

def flag!
auto_silence_account!
auto_report_status!
end

def remember!
# The scores in sorted sets don't actually have enough bits to hold an exact
# value of our snowflake IDs, so we use it only for its ordering property. To
# get the correct status ID back, we have to save it in the string value

redis.zadd(redis_key, @status.id, digest_with_algorithm)
redis.zremrangebyrank(redis_key, '0', '-10')
redis.expire(redis_key, EXPIRE_SET_AFTER)
end

def reset!
redis.del(redis_key)
end

def hashable_text
return @hashable_text if defined?(@hashable_text)

@hashable_text = @status.text
@hashable_text = remove_mentions(@hashable_text)
@hashable_text = strip_tags(@hashable_text) unless @status.local?
@hashable_text = normalize_unicode(@status.spoiler_text + ' ' + @hashable_text)
@hashable_text = remove_whitespace(@hashable_text)
end

def insufficient_data?
hashable_text.blank?
end

def digest
@digest ||= begin
if nilsimsa?
Nilsimsa.new(hashable_text).hexdigest
else
Digest::MD5.hexdigest(hashable_text)
end
end
end

def digest_with_algorithm
if nilsimsa?
['nilsimsa', digest, @status.id].join(':')
else
['md5', digest, @status.id].join(':')
end
end

private

def remove_mentions(text)
return text.gsub(Account::MENTION_RE, '') if @status.local?

Nokogiri::HTML.fragment(text).tap do |html|
mentions = @status.mentions.map { |mention| ActivityPub::TagManager.instance.url_for(mention.account) }

html.traverse do |element|
element.unlink if element.name == 'a' && mentions.include?(element['href'])
end
end.to_s
end

def normalize_unicode(text)
text.unicode_normalize(:nfkc).downcase
end

def remove_whitespace(text)
text.gsub(/\s+/, ' ').strip
end

def auto_silence_account!
@account.silence!
end

def auto_report_status!
status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable?
ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected_and_silenced'))
end

def already_flagged?
@account.silenced?
end

def trusted?
@account.trust_level > Account::TRUST_LEVELS[:untrusted]
end

def no_unsolicited_mentions?
@status.mentions.all? { |mention| mention.silent? || (!@account.local? && !mention.account.local?) || mention.account.following?(@account) }
end

def solicited_reply?
!@status.thread.nil? && @status.thread.mentions.where(account: @account).exists?
end

def nilsimsa_compare_value(first, second)
first = [first].pack('H*')
second = [second].pack('H*')
bits = 0

0.upto(31) do |i|
bits += Nilsimsa::POPC[255 & (first[i].ord ^ second[i].ord)].ord
end

128 - bits # -128 <= Nilsimsa Compare Value <= 128
end

def nilsimsa?
hashable_text.size > NILSIMSA_MIN_SIZE
end

def other_digests
redis.zrange(redis_key, 0, -1)
end

def any_other_digest?(filter_algorithm)
other_digests.any? do |record|
algorithm, other_digest, status_id = record.split(':')

next unless algorithm == filter_algorithm

yield algorithm, other_digest, status_id
end
end

def matching_status_ids
if nilsimsa?
other_digests.select { |record| record.start_with?('nilsimsa') && nilsimsa_compare_value(digest, record.split(':')[1]) >= NILSIMSA_COMPARE_THRESHOLD }.map { |record| record.split(':')[2] }.compact
else
other_digests.select { |record| record.start_with?('md5') && record.split(':')[1] == digest }.map { |record| record.split(':')[2] }.compact
end
end

def redis_key
@redis_key ||= "spam_check:#{@account.id}"
end
end
18 changes: 13 additions & 5 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
# also_known_as :string is an Array
# silenced_at :datetime
# suspended_at :datetime
# trust_level :integer
#

class Account < ApplicationRecord
Expand All @@ -63,6 +64,11 @@ class Account < ApplicationRecord
include AccountCounters
include DomainNormalizable

TRUST_LEVELS = {
untrusted: 0,
trusted: 1,
}.freeze

enum protocol: [:ostatus, :activitypub]

validates :username, presence: true
Expand Down Expand Up @@ -164,6 +170,10 @@ def possibly_stale?
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
end

def trust_level
self[:trust_level] || 0
end

def refresh!
ResolveAccountService.new.call(acct) unless local?
end
Expand All @@ -172,21 +182,19 @@ def silenced?
silenced_at.present?
end

def silence!(date = nil)
date ||= Time.now.utc
def silence!(date = Time.now.utc)
update!(silenced_at: date)
end

def unsilence!
update!(silenced_at: nil)
update!(silenced_at: nil, trust_level: trust_level == TRUST_LEVELS[:untrusted] ? TRUST_LEVELS[:trusted] : trust_level)
end

def suspended?
suspended_at.present?
end

def suspend!(date = nil)
date ||= Time.now.utc
def suspend!(date = Time.now.utc)
transaction do
user&.disable! if local?
update!(suspended_at: date)
Expand Down
5 changes: 5 additions & 0 deletions app/services/remove_status_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def call(status, **options)
remove_from_hashtags
remove_from_public
remove_from_media if status.media_attachments.any?
remove_from_spam_check

@status.destroy!
else
Expand Down Expand Up @@ -142,6 +143,10 @@ def remove_from_media
redis.publish('timeline:public:local:media', @payload) if @status.local?
end

def remove_from_spam_check
redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
end

def lock_options
{ redis: Redis.current, key: "distribute:#{@status.id}" }
end
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,8 @@ en:
profile: Profile
relationships: Follows and followers
two_factor_authentication: Two-factor Auth
spam_check:
spam_detected_and_silenced: This is an automated report. Spam has been detected and the sender has been silenced automatically. If this is a mistake, please unsilence the account.
statuses:
attached:
description: 'Attached: %{attached}'
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20190701022101_add_trust_level_to_accounts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddTrustLevelToAccounts < ActiveRecord::Migration[5.2]
def change
add_column :accounts, :trust_level, :integer
end
end
1 change: 1 addition & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
t.string "also_known_as", array: true
t.datetime "silenced_at"
t.datetime "suspended_at"
t.integer "trust_level"
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id"
Expand Down
Loading

0 comments on commit 132e91b

Please sign in to comment.