Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/admin/report/abuse_contacts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def abuse_contact_params
:organization,
:trusted_reporter,
:accepts_bulk,
:accepts_xarf,
:priority,
:active,
:notes,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/xarf_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# The public XARF utility.
#
# Anyone can paste a link and get a XARF v4 report back. Sending that report to
# Anyone can paste a link and get an X-ARF report back. Sending that report to
# the registrar and hosting provider needs an account, because that path puts
# mail in someone else's inbox under our name.
class XarfController < ApplicationController
Expand Down
20 changes: 20 additions & 0 deletions app/jobs/digital_ocean_range_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# Refreshes the IP ranges on the DigitalOcean abuse contact.
# DigitalOcean reallocates blocks, so a stale list makes the report pipeline
# miss sites it should report.
class DigitalOceanRangeSyncJob < ApplicationJob
queue_as QUEUE_MAINTENANCE

def perform
Rails.logger.info("[DigitalOceanRangeSyncJob] Starting DigitalOcean IP range sync...")

result = Report::DigitalOceanRangeService.new.sync

if result[:success]
Rails.logger.info("[DigitalOceanRangeSyncJob] Sync complete: #{result[:ranges]} ranges stored")
else
Rails.logger.error("[DigitalOceanRangeSyncJob] Sync failed: #{result[:error]}")
end
end
end
64 changes: 60 additions & 4 deletions app/mailers/report/abuse_report_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,73 @@ def abuse_report
@contact = params[:contact]
@payload = (@submission.payload.presence || @submission.build_payload).with_indifferent_access

# No env_subject - reports are always sent to real external contacts
# regardless of environment (only legit phishing domains are reported)
mail(
@contact.accepts_xarf? ? xarf_report_mail : standard_report_mail
end

private

def mail_headers
{
to: @contact.email,
cc: @case.email_address, # case_xxx@cases.phish.directory for reply threading
reply_to: [
"support@phish.directory",
@case.email_address
],
# No env_subject - reports are always sent to real external contacts
# regardless of environment (only legit phishing domains are reported)
subject: "[Automated] [Phishing Report] #{@case.domain_name} - Case #{@case.case_number}"
)
}
end

def standard_report_mail
mail(**mail_headers)
end

# Build the report the way https://github.com/abusix/xarf specifies for
# SMTP: an RFC 5965 feedback report whose third part carries the machine
# readable document.
#
# multipart/report; report-type=feedback-report
# text/plain the human readable report
# message/feedback-report Feedback-Type: xarf, so an ARF parser stops
# here and an X-ARF parser reads the next part
# application/json xarf.json, the report itself
#
# The HTML part is left out on purpose. RFC 6522 puts the human readable
# part first, and a lone text/plain keeps the structure identical to the
# published example, which is what these parsers are written against.
def xarf_report_mail
message = mail(**mail_headers) do |format|
format.text { render "abuse_report" }
end

message.add_part(feedback_report_part)
message.attachments["xarf.json"] = {
mime_type: "application/json",
content: JSON.pretty_generate(
Xarf::ReportGenerator.new.generate_for_submission(@submission)
)
}

# add_part set a boundary while wrapping the body, so reuse it rather
# than letting the new Content-Type drop it.
message.content_type =
%(multipart/report; report-type=feedback-report; boundary="#{message.body.boundary}")

message
end

def feedback_report_part
Mail::Part.new do
content_type "message/feedback-report"
content_disposition "inline"
body [
"Feedback-Type: xarf",
"User-Agent: phish.directory/#{ENV.fetch('RELEASE_VERSION', '1.0.0')}",
"Version: 1"
].join("\r\n")
end
end
end
end
46 changes: 46 additions & 0 deletions app/models/report/abuse_contact.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class Report::AbuseContact < ApplicationRecord
scope :security_vendors, -> { where(contact_type: "security_vendor") }
scope :trusted, -> { where(trusted_reporter: true) }
scope :by_priority, -> { order(priority: :asc) }
scope :with_ip_ranges, -> { where("jsonb_array_length(ip_ranges) > 0") }

# Class methods
class << self
Expand All @@ -86,13 +87,49 @@ def find_for_nameservers(nameservers)
end
end

# Find contact whose published IP ranges cover any of the given addresses
#
# Nameserver patterns only identify a host when the site also uses that
# host's DNS, which phishing sites usually do not. Matching the addresses a
# domain actually resolves to is what finds the provider serving the page.
#
# @param addresses [Array<String>] IP addresses the domain resolves to
# @return [Report::AbuseContact, nil]
def find_for_ip(addresses)
ips = Array(addresses).filter_map { |address| parse_ip(address) }
return nil if ips.empty?

active.with_ip_ranges.by_priority.find { |contact| contact.covers_ip?(ips) }
end

# Get all contacts that should always receive reports
def always_report_to
active.trusted.by_priority
end

private

def parse_ip(address)
IPAddr.new(address.to_s)
rescue IPAddr::InvalidAddressError
nil
end
end

# Instance methods

# Check whether any of the given addresses falls inside this contact's ranges
#
# @param addresses [Array<IPAddr>] parsed addresses
# @return [Boolean]
def covers_ip?(addresses)
return false if cidr_ranges.empty?

Array(addresses).any? do |address|
cidr_ranges.any? { |range| range.include?(address) }
end
end

def operational?
active? && kept?
end
Expand All @@ -113,6 +150,15 @@ def display_name

private

# DigitalOcean publishes around 1200 ranges, so parse them once per record.
def cidr_ranges
@cidr_ranges ||= Array(ip_ranges).filter_map do |range|
IPAddr.new(range.to_s)
rescue IPAddr::InvalidAddressError
nil
end
end

def update_response_stats!
acknowledged = submissions.where.not(acknowledged_at: nil)
return if acknowledged.empty?
Expand Down
18 changes: 17 additions & 1 deletion app/models/report/domain_lookup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,26 @@ def mark_looked_up!(source: nil)
end

# Update matched contacts based on patterns
#
# Hosting falls back to the addresses the domain resolves to. A phishing site
# on a DigitalOcean droplet normally keeps its registrar's nameservers, so
# nameserver patterns alone never identify the provider serving the page.
def match_contacts!
self.matched_registrar_contact = Report::AbuseContact.find_for_registrar(registrar_name)
self.matched_hosting_contact = Report::AbuseContact.find_for_nameservers(nameservers)
self.matched_hosting_contact =
Report::AbuseContact.find_for_nameservers(nameservers) ||
Report::AbuseContact.find_for_ip(resolved_addresses)
self.hosting_provider = matched_hosting_contact.name if matched_hosting_contact
save!
end

# Every address the domain resolves to, both families
#
# @return [Array<String>]
def resolved_addresses
Array(a_records) + Array(aaaa_records)
end

# Get all matched contacts
def matched_contacts
[ matched_registrar_contact, matched_hosting_contact ].compact.uniq
Expand All @@ -82,6 +96,8 @@ def to_summary
registrar_name: registrar_name,
registrar_abuse_email: registrar_abuse_email,
nameservers: nameservers,
a_records: a_records,
aaaa_records: aaaa_records,
hosting_provider: hosting_provider,
domain_created_at: domain_created_at&.iso8601,
domain_expires_at: domain_expires_at&.iso8601,
Expand Down
62 changes: 62 additions & 0 deletions app/services/report/digital_ocean_range_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

module Report
# Keeps the DigitalOcean abuse contact's IP ranges current.
#
# DigitalOcean publishes its allocations as a CSV whose first column is the
# CIDR block. Report::AbuseContact.find_for_ip matches a phishing domain's A
# records against those blocks, which is how the pipeline decides that
# DigitalOcean is the host to notify.
#
# Usage:
# Report::DigitalOceanRangeService.new.sync
#
class DigitalOceanRangeService < BaseService
RANGES_URL = "https://digitalocean.com/geo/google.csv"
CONTACT_NAME = "DigitalOcean"

def sync
cidrs = parse_cidrs(fetch_ranges)

if cidrs.empty?
log_error("Range list was empty", ServiceError.new("no usable CIDR blocks"))
return { success: false, error: "No IP ranges returned" }
end

contact = Report::AbuseContact.find_by(name: CONTACT_NAME)

unless contact
return { success: false, error: "#{CONTACT_NAME} abuse contact is not seeded" }
end

contact.update!(ip_ranges: cidrs)
log_info("Stored #{cidrs.size} DigitalOcean IP ranges")

{ success: true, ranges: cidrs.size }
rescue ServiceError => e
{ success: false, error: e.message }
end

private

def fetch_ranges
conn = connection(base_url: RANGES_URL, headers: { "Accept" => "text/csv" })
get(conn, "").to_s
end

# Rows look like "5.101.96.0/21,NL,NL-NH,Amsterdam,1098 XH". Anything that
# is not a CIDR block is dropped rather than stored and matched against
# later.
def parse_cidrs(body)
body.each_line.filter_map do |line|
cidr = line.split(",").first.to_s.strip
next if cidr.blank?

IPAddr.new(cidr)
cidr
rescue IPAddr::InvalidAddressError
nil
end
end
end
end
33 changes: 33 additions & 0 deletions app/services/report/domain_lookup_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Report
# Uses RDAP (preferred) with WHOIS fallback
class DomainLookupService < BaseService
CACHE_TTL = 24.hours
DNS_TIMEOUT = 5

# RDAP bootstrap servers by TLD
# See: https://data.iana.org/rdap/dns.json
Expand Down Expand Up @@ -37,6 +38,10 @@ def lookup(domain)
result = lookup_rdap(domain) || lookup_whois(domain)

if result
# The hosting provider is found from the addresses the domain serves
# from, not from the registration record, so resolve them here.
result[:a_records] = resolve_addresses(domain, Resolv::DNS::Resource::IN::A)
result[:aaaa_records] = resolve_addresses(domain, Resolv::DNS::Resource::IN::AAAA)
save_lookup(domain, result)
else
log_info("No lookup data found for #{domain}")
Expand All @@ -50,6 +55,32 @@ def normalize_domain(domain)
domain.to_s.downcase.strip.sub(/^www\./, "")
end

# Resolve the addresses the domain currently serves from.
#
# Report::AbuseContact.find_for_ip matches these against the ranges a
# hosting provider publishes, which is how a report reaches the provider
# that actually serves the phishing page. Both families are resolved:
# DigitalOcean and its peers publish IPv6 allocations too, and an
# IPv6-only host would otherwise never be matched.
#
# A domain that does not resolve is still worth reporting on the
# registration record alone, so every failure here degrades to no addresses
# rather than losing the lookup.
#
# @param domain [String] normalized domain
# @param resource [Class] Resolv::DNS::Resource::IN::A or ::AAAA
# @return [Array<String>] addresses, empty when the domain does not resolve
def resolve_addresses(domain, resource)
Timeout.timeout(DNS_TIMEOUT) do
Resolv::DNS.open do |dns|
dns.getresources(domain, resource).map { |record| record.address.to_s }
end
end
rescue StandardError => e
log_debug("Could not resolve #{resource.name.demodulize} records for #{domain}: #{e.message}")
[]
end

def lookup_rdap(domain)
tld = domain.split(".").last&.downcase
rdap_url = RDAP_SERVERS[tld]
Expand Down Expand Up @@ -218,6 +249,8 @@ def save_lookup(domain, result)
domain_created_at: result[:domain_created_at],
domain_expires_at: result[:domain_expires_at],
nameservers: result[:nameservers],
a_records: result[:a_records] || [],
aaaa_records: result[:aaaa_records] || [],
lookup_source: result[:lookup_source],
looked_up_at: Time.current,
expires_at: CACHE_TTL.from_now
Expand Down
Loading