From 898fe968694563573f66bad9d9983c5a03d31cc2 Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Mon, 7 Sep 2026 11:30:31 -0400 Subject: [PATCH 1/2] feat: report phishing domains to DigitalOcean in X-ARF format DigitalOcean processes its abuse mailbox with automated tooling that only accepts an X-ARF attachment, so a prose report is dropped. Closes #54. Contacts carry an accepts_xarf flag. When it is set, the mailer builds the SMTP envelope from https://github.com/abusix/xarf instead of the HTML report: multipart/report with a text/plain part, a message/feedback-report part that carries Feedback-Type: xarf, and the document as xarf.json. The HTML part is dropped on that path, because RFC 6522 puts the human readable part first. "Where applicable" needed hosting detection to work at all. Hosting was matched on nameserver patterns alone, but a phishing site on a droplet keeps its registrar's nameservers, so DigitalOcean was never matched. Domain lookups now resolve A and AAAA records, Report::AbuseContact.find_for_ip matches them against published CIDR ranges, and DigitalOceanRangeSyncJob keeps those ranges current from DigitalOcean's published CSV. Also corrects the X-ARF layer. Xarf::ReportGenerator claimed version 4.0.0 with snake_case fields, and no such schema exists: abusix publishes 1, 2 and 3, all PascalCase. The generator, parser and category mapper now speak schema 3, and Xarf::EmailReportBuilder is folded into the generator so the admin UI and the wire share one representation rather than two that disagree. Output was checked against schemas/3/phishing.schema.json. Claude-Session: https://claude.ai/code/session_01UJuHu239wz4x62pLNt651V --- .../admin/report/abuse_contacts_controller.rb | 1 + app/jobs/digital_ocean_range_sync_job.rb | 20 + app/mailers/report/abuse_report_mailer.rb | 64 ++- app/models/report/abuse_contact.rb | 46 ++ app/models/report/domain_lookup.rb | 18 +- .../report/digital_ocean_range_service.rb | 62 +++ app/services/report/domain_lookup_service.rb | 33 ++ app/services/xarf/category_mapper.rb | 399 +++++++---------- app/services/xarf/report_generator.rb | 400 ++++++------------ app/services/xarf/report_parser.rb | 318 ++++++-------- .../report/abuse_contacts/_form.html.erb | 6 + app/views/reports/case_report.html.erb | 12 + config/recurring.yml | 6 + ...d_accepts_xarf_to_report_abuse_contacts.rb | 13 + ...d_aaaa_records_to_report_domain_lookups.rb | 14 + db/seeds/abuse_contacts.rb | 1 + .../report/abuse_report_mailer_test.rb | 93 ++++ test/models/report/abuse_contact_test.rb | 71 ++++ test/models/report/domain_lookup_test.rb | 70 +++ .../digital_ocean_range_service_test.rb | 82 ++++ test/services/xarf/category_mapper_test.rb | 80 ++++ test/services/xarf/report_generator_test.rb | 191 +++++++++ test/services/xarf/report_parser_test.rb | 149 +++++++ test/test_helper.rb | 34 ++ 24 files changed, 1471 insertions(+), 712 deletions(-) create mode 100644 app/jobs/digital_ocean_range_sync_job.rb create mode 100644 app/services/report/digital_ocean_range_service.rb create mode 100644 db/migrate/20260907120001_add_accepts_xarf_to_report_abuse_contacts.rb create mode 100644 db/migrate/20260907120002_add_aaaa_records_to_report_domain_lookups.rb create mode 100644 test/mailers/report/abuse_report_mailer_test.rb create mode 100644 test/models/report/abuse_contact_test.rb create mode 100644 test/models/report/domain_lookup_test.rb create mode 100644 test/services/report/digital_ocean_range_service_test.rb create mode 100644 test/services/xarf/category_mapper_test.rb create mode 100644 test/services/xarf/report_generator_test.rb create mode 100644 test/services/xarf/report_parser_test.rb diff --git a/app/controllers/admin/report/abuse_contacts_controller.rb b/app/controllers/admin/report/abuse_contacts_controller.rb index 05ef661..a3d5fdf 100644 --- a/app/controllers/admin/report/abuse_contacts_controller.rb +++ b/app/controllers/admin/report/abuse_contacts_controller.rb @@ -105,6 +105,7 @@ def abuse_contact_params :organization, :trusted_reporter, :accepts_bulk, + :accepts_xarf, :priority, :active, :notes, diff --git a/app/jobs/digital_ocean_range_sync_job.rb b/app/jobs/digital_ocean_range_sync_job.rb new file mode 100644 index 0000000..0db6ae5 --- /dev/null +++ b/app/jobs/digital_ocean_range_sync_job.rb @@ -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 diff --git a/app/mailers/report/abuse_report_mailer.rb b/app/mailers/report/abuse_report_mailer.rb index 11ccae8..fc708e3 100644 --- a/app/mailers/report/abuse_report_mailer.rb +++ b/app/mailers/report/abuse_report_mailer.rb @@ -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 diff --git a/app/models/report/abuse_contact.rb b/app/models/report/abuse_contact.rb index 7ba88d7..dd6eaef 100644 --- a/app/models/report/abuse_contact.rb +++ b/app/models/report/abuse_contact.rb @@ -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 @@ -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] 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] 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 @@ -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? diff --git a/app/models/report/domain_lookup.rb b/app/models/report/domain_lookup.rb index 6c63e2c..d680982 100644 --- a/app/models/report/domain_lookup.rb +++ b/app/models/report/domain_lookup.rb @@ -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] + 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 @@ -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, diff --git a/app/services/report/digital_ocean_range_service.rb b/app/services/report/digital_ocean_range_service.rb new file mode 100644 index 0000000..9988c13 --- /dev/null +++ b/app/services/report/digital_ocean_range_service.rb @@ -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 diff --git a/app/services/report/domain_lookup_service.rb b/app/services/report/domain_lookup_service.rb index 588a8c7..f67a1fb 100644 --- a/app/services/report/domain_lookup_service.rb +++ b/app/services/report/domain_lookup_service.rb @@ -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 @@ -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}") @@ -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] 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] @@ -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 diff --git a/app/services/xarf/category_mapper.rb b/app/services/xarf/category_mapper.rb index b3905e1..569984f 100644 --- a/app/services/xarf/category_mapper.rb +++ b/app/services/xarf/category_mapper.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true module Xarf - # Maps between phish.directory classifications and XARF v4 categories/types + # Maps between phish.directory classifications and the X-ARF schema 3 + # taxonomy published at https://github.com/abusix/xarf. # - # XARF v4 Categories (7): - # - connection: Network-level attacks (login_attack, port_scan, ddos, etc.) - # - content: Malicious/harmful content (phishing, malware, fraud, etc.) - # - copyright: IP infringement (copyright, p2p, cyberlocker, etc.) - # - infrastructure: Compromised systems (botnet, compromised_server) - # - messaging: Spam and bulk messaging (spam, bulk_messaging) - # - reputation: Threat intel and blocklists (blocklist, threat_intelligence) - # - vulnerability: Security issues (cve, open, misconfiguration) + # Schema 3 splits a report into a ReportClass and a ReportType. There are + # three classes and one type per schema file: + # + # Content Phishing, Malware, Copyright, Trademark, ChildAbuse, Botnet + # Activity Spam, DOS, PortScan, LoginAttack, Exploit, WebCrawler, + # Harassment, PotentiallyCompromisedAccount, Malware (RPZ) + # Vulnerability OpenService # # phish.directory Classifications: # - phishing: Confirmed phishing sites @@ -20,182 +20,121 @@ module Xarf # - protected: Protected domains (whitelisted) # class CategoryMapper - # XARF v4 categories - XARF_CATEGORIES = %w[ - connection - content - copyright - infrastructure - messaging - reputation - vulnerability - ].freeze - - # XARF v4 types organized by category - XARF_TYPES = { - connection: %w[ - login_attack - port_scan - ddos - infected_host - reconnaissance - scraping - sql_injection - vuln_scanning - ].freeze, - content: %w[ - phishing - malware - csam - csem - exposed_data - brand_infringement - fraud - remote_compromise - suspicious_registration - ].freeze, - copyright: %w[ - copyright - p2p - cyberlocker - ugc_platform - link_site - usenet + REPORT_CLASSES = %w[Content Activity Vulnerability].freeze + + # Types by class, taken from the schema 3 files. Malware appears twice + # because the RPZ schema reports it as Activity with an RPZ-Rewrite + # subtype, while the malware schema reports it as Content. + REPORT_TYPES = { + "Content" => %w[Phishing Malware Copyright Trademark ChildAbuse Botnet].freeze, + "Activity" => %w[ + Spam + DOS + PortScan + LoginAttack + Exploit + PotentiallyCompromisedAccount + WebCrawler + Harassment + Malware ].freeze, - infrastructure: %w[ - botnet - compromised_server - ].freeze, - messaging: %w[ - spam - bulk_messaging - ].freeze, - reputation: %w[ - blocklist - threat_intelligence - ].freeze, - vulnerability: %w[ - cve - open - misconfiguration - ].freeze + "Vulnerability" => %w[OpenService].freeze }.freeze - # Mapping from phish.directory classification to XARF category/type + # Mapping from phish.directory classification to X-ARF class/type. + # + # Schema 3 has no type for an unconfirmed site, so "suspicious" is reported + # as Phishing too. ReporterSeverity and the notes carry the confidence, so + # the receiving desk still sees that the finding is not confirmed. CLASSIFICATION_TO_XARF = { - "phishing" => { category: "content", type: "phishing" }, - "suspicious" => { category: "content", type: "suspicious_registration" }, - "clean" => nil, # Clean domains don't need XARF reports - "unknown" => nil, # Unknown domains don't have enough info for XARF + "phishing" => { report_class: "Content", report_type: "Phishing" }, + "suspicious" => { report_class: "Content", report_type: "Phishing" }, + "clean" => nil, # Clean domains don't need X-ARF reports + "unknown" => nil, # Unknown domains don't have enough info for X-ARF "protected" => nil # Protected domains shouldn't be reported }.freeze - # Mapping from XARF type to phish.directory classification + # Mapping from X-ARF type to phish.directory classification. + # nil means the type is real but outside what this directory classifies. XARF_TYPE_TO_CLASSIFICATION = { - # Content types - "phishing" => "phishing", - "malware" => "phishing", - "fraud" => "phishing", - "brand_infringement" => "suspicious", - "suspicious_registration" => "suspicious", - "exposed_data" => "suspicious", - "remote_compromise" => "phishing", - "csam" => "phishing", - "csem" => "phishing", - - # Connection types (usually infrastructure-level, map to suspicious) - "login_attack" => "suspicious", - "port_scan" => "suspicious", - "ddos" => "suspicious", - "infected_host" => "phishing", - "reconnaissance" => "suspicious", - "scraping" => "suspicious", - "sql_injection" => "phishing", - "vuln_scanning" => "suspicious", - - # Infrastructure types - "botnet" => "phishing", - "compromised_server" => "phishing", - - # Messaging types - "spam" => "suspicious", - "bulk_messaging" => "suspicious", - - # Reputation types (informational) - "blocklist" => "suspicious", - "threat_intelligence" => "suspicious", - - # Copyright types (not typically phishing) - "copyright" => nil, - "p2p" => nil, - "cyberlocker" => nil, - "ugc_platform" => nil, - "link_site" => nil, - "usenet" => nil, - - # Vulnerability types - "cve" => "suspicious", - "open" => "suspicious", - "misconfiguration" => "suspicious" + "Phishing" => "phishing", + "Malware" => "phishing", + "Botnet" => "phishing", + "Exploit" => "phishing", + "Trademark" => "suspicious", + "Spam" => "suspicious", + "LoginAttack" => "suspicious", + "PortScan" => "suspicious", + "DOS" => "suspicious", + "WebCrawler" => "suspicious", + "PotentiallyCompromisedAccount" => "suspicious", + "OpenService" => "suspicious", + "Copyright" => nil, + "ChildAbuse" => nil, + "Harassment" => nil }.freeze - # Confidence score adjustments based on XARF type - # Higher values = more confidence the mapping is accurate + # Confidence score for an incoming type to classification mapping. + # Higher values = more confidence the mapping is accurate. XARF_TYPE_CONFIDENCE = { - "phishing" => 1.0, - "malware" => 0.95, - "fraud" => 0.9, - "infected_host" => 0.85, - "botnet" => 0.85, - "compromised_server" => 0.8, - "brand_infringement" => 0.7, - "suspicious_registration" => 0.6, - "spam" => 0.5, - "blocklist" => 0.6, - "threat_intelligence" => 0.7 + "Phishing" => 1.0, + "Malware" => 0.95, + "Botnet" => 0.85, + "Exploit" => 0.8, + "Trademark" => 0.7, + "Spam" => 0.5, + "LoginAttack" => 0.5, + "PortScan" => 0.5, + "DOS" => 0.5, + "WebCrawler" => 0.4, + "PotentiallyCompromisedAccount" => 0.6, + "OpenService" => 0.5 }.freeze DEFAULT_CONFIDENCE = 0.5 + # Severity thresholds. ReporterSeverity is a closed low/medium/high enum. + HIGH_CONFIDENCE = 0.9 + MEDIUM_CONFIDENCE = 0.7 + class << self - # Convert phish.directory classification to XARF category/type + # Convert phish.directory classification to X-ARF class/type # # @param classification [String] phish.directory classification - # @param subtype [String, nil] optional subtype for more specific mapping - # @return [Hash, nil] { category:, type: } or nil if not mappable - def to_xarf(classification, subtype: nil) + # @param report_type [String, nil] optional override for a more specific type + # @return [Hash, nil] { report_class:, report_type: } or nil if not mappable + def to_xarf(classification, report_type: nil) mapping = CLASSIFICATION_TO_XARF[classification.to_s] return nil if mapping.nil? - # Allow subtype override for more specific mappings - if subtype && valid_xarf_type?(subtype) - { category: category_for_type(subtype), type: subtype } + if report_type && valid_report_type?(report_type) + { report_class: classes_for_type(report_type).first, report_type: report_type.to_s } else mapping end end - # Convert XARF category/type to phish.directory classification + # Convert X-ARF class/type to phish.directory classification # - # @param category [String] XARF category - # @param type [String] XARF type + # @param report_class [String] X-ARF ReportClass + # @param report_type [String] X-ARF ReportType # @return [String, nil] phish.directory classification or nil - def from_xarf(category, type) - return nil unless valid_xarf_category?(category) - return nil unless valid_xarf_type?(type) + def from_xarf(report_class, report_type) + return nil unless valid_report_class?(report_class) + return nil unless valid_report_type?(report_type) - XARF_TYPE_TO_CLASSIFICATION[type.to_s] + XARF_TYPE_TO_CLASSIFICATION[report_type.to_s] end - # Get confidence score for XARF type to classification mapping + # Get confidence score for an X-ARF type to classification mapping # - # @param type [String] XARF type + # @param report_type [String] X-ARF ReportType # @return [Float] confidence score (0.0 - 1.0) - def confidence_for_type(type) - XARF_TYPE_CONFIDENCE.fetch(type.to_s, DEFAULT_CONFIDENCE) + def confidence_for_type(report_type) + XARF_TYPE_CONFIDENCE.fetch(report_type.to_s, DEFAULT_CONFIDENCE) end - # Check if a classification is reportable via XARF + # Check if a classification is reportable via X-ARF # # @param classification [String] phish.directory classification # @return [Boolean] @@ -203,128 +142,110 @@ def reportable?(classification) CLASSIFICATION_TO_XARF[classification.to_s].present? end - # Get the XARF category for a given type + # Get every ReportClass a type may appear under # - # @param type [String] XARF type - # @return [String, nil] XARF category or nil - def category_for_type(type) - XARF_TYPES.each do |category, types| - return category.to_s if types.include?(type.to_s) - end - nil + # @param report_type [String] X-ARF ReportType + # @return [Array] classes, most common first + def classes_for_type(report_type) + REPORT_TYPES.select { |_klass, types| types.include?(report_type.to_s) }.keys end - # Validate XARF category + # Validate a ReportClass # - # @param category [String] XARF category + # @param report_class [String] # @return [Boolean] - def valid_xarf_category?(category) - XARF_CATEGORIES.include?(category.to_s) + def valid_report_class?(report_class) + REPORT_CLASSES.include?(report_class.to_s) end - # Validate XARF type + # Validate a ReportType # - # @param type [String] XARF type + # @param report_type [String] # @return [Boolean] - def valid_xarf_type?(type) - XARF_TYPES.values.flatten.include?(type.to_s) + def valid_report_type?(report_type) + REPORT_TYPES.values.flatten.include?(report_type.to_s) end - # Get all XARF types for a category + # Check that a type is allowed under a class # - # @param category [String, Symbol] XARF category + # @param report_class [String] + # @param report_type [String] + # @return [Boolean] + def type_in_class?(report_class, report_type) + REPORT_TYPES.fetch(report_class.to_s, []).include?(report_type.to_s) + end + + # Get all types for a class + # + # @param report_class [String] # @return [Array] list of types - def types_for_category(category) - XARF_TYPES[category.to_sym] || [] + def types_for_class(report_class) + REPORT_TYPES.fetch(report_class.to_s, []) end - # Map verdict object to XARF category/type with full context + # Map a verdict onto an X-ARF class/type with full context # # @param verdict [Verdict] verdict record - # @return [Hash] { category:, type:, confidence:, reportable: } + # @return [Hash] { report_class:, report_type:, confidence:, reportable: } def map_verdict(verdict) return { reportable: false } if verdict.nil? - xarf_mapping = to_xarf(verdict.classification) + mapping = to_xarf(verdict.classification) + return { reportable: false } unless mapping - if xarf_mapping - { - category: xarf_mapping[:category], - type: xarf_mapping[:type], - confidence: verdict.confidence_score || DEFAULT_CONFIDENCE, - reportable: true - } - else - { reportable: false } + mapping.merge( + confidence: verdict.confidence_score || DEFAULT_CONFIDENCE, + reportable: true + ) + end + + # Severity for a confidence score, as the ReporterSeverity enum + # + # @param confidence [Float, nil] + # @return [String] "low", "medium" or "high" + def severity_for_confidence(confidence) + case confidence.to_f + when HIGH_CONFIDENCE.. then "high" + when MEDIUM_CONFIDENCE...HIGH_CONFIDENCE then "medium" + else "low" end end - # Get descriptive info about a XARF type + # Describe an X-ARF type # - # @param type [String] XARF type - # @return [Hash] { category:, description:, severity: } - def type_info(type) - category = category_for_type(type) - return nil unless category + # @param report_type [String] X-ARF ReportType + # @return [Hash, nil] { report_class:, report_type:, description: } + def type_info(report_type) + classes = classes_for_type(report_type) + return nil if classes.empty? { - category: category, - type: type, - description: type_description(type), - severity: type_severity(type) + report_class: classes.first, + report_type: report_type.to_s, + description: type_description(report_type) } end private - def type_description(type) + def type_description(report_type) { - # Content types - "phishing" => "Fraudulent attempt to obtain sensitive information", - "malware" => "Malicious software distribution", - "fraud" => "Deceptive practices for financial gain", - "brand_infringement" => "Unauthorized use of brand identity", - "suspicious_registration" => "Domain registered with suspicious patterns", - "exposed_data" => "Sensitive data exposure", - "remote_compromise" => "Remote system compromise", - "csam" => "Child sexual abuse material", - "csem" => "Child sexual exploitation material", - - # Connection types - "login_attack" => "Brute force or credential stuffing attack", - "port_scan" => "Network port scanning activity", - "ddos" => "Distributed denial of service attack", - "infected_host" => "Compromised host exhibiting malicious behavior", - "reconnaissance" => "Information gathering for potential attack", - "scraping" => "Unauthorized data scraping", - "sql_injection" => "SQL injection attack attempt", - "vuln_scanning" => "Vulnerability scanning activity", - - # Infrastructure types - "botnet" => "Part of a botnet command and control", - "compromised_server" => "Server showing signs of compromise", - - # Messaging types - "spam" => "Unsolicited bulk messaging", - "bulk_messaging" => "High-volume messaging campaign", - - # Reputation types - "blocklist" => "Listed on security blocklist", - "threat_intelligence" => "Identified in threat intelligence feed" - }.fetch(type.to_s, "Unknown abuse type") - end - - def type_severity(type) - case type.to_s - when "phishing", "malware", "csam", "csem", "remote_compromise", "botnet" - "critical" - when "fraud", "infected_host", "compromised_server", "sql_injection" - "high" - when "brand_infringement", "login_attack", "ddos", "spam" - "medium" - else - "low" - end + "Phishing" => "Fraudulent attempt to obtain sensitive information", + "Malware" => "Malicious software distribution", + "Copyright" => "Copyright infringing content", + "Trademark" => "Unauthorized use of brand identity", + "ChildAbuse" => "Child sexual abuse material", + "Botnet" => "Botnet command and control", + "Spam" => "Unsolicited bulk messaging", + "DOS" => "Denial of service attack", + "PortScan" => "Network port scanning activity", + "LoginAttack" => "Brute force or credential stuffing attack", + "Exploit" => "Attempt to exploit a vulnerability", + "PotentiallyCompromisedAccount" => "Account showing signs of compromise", + "WebCrawler" => "Unwanted automated crawling", + "Harassment" => "Harassment of an individual", + "OpenService" => "Service exposed that should not be reachable" + }.fetch(report_type.to_s, "Unknown abuse type") end end end diff --git a/app/services/xarf/report_generator.rb b/app/services/xarf/report_generator.rb index dbb29f1..f9895c1 100644 --- a/app/services/xarf/report_generator.rb +++ b/app/services/xarf/report_generator.rb @@ -1,7 +1,12 @@ # frozen_string_literal: true module Xarf - # Generates XARF v4 compliant reports from phish.directory data + # Generates X-ARF reports from phish.directory data. + # + # Output follows schema 3 of https://github.com/abusix/xarf, which is the + # newest published schema. It is PascalCase and nests everything under + # ReporterInfo and Report. Abuse desks with automated tooling validate + # against it, so the same document serves the admin UI and the wire. # # Usage: # generator = Xarf::ReportGenerator.new @@ -15,16 +20,20 @@ module Xarf # # Generate from a verdict # report = generator.generate_for_verdict(verdict, source_type: :domain, source: domain) # + # # Generate the document that goes out as an email's xarf.json attachment + # report = generator.generate_for_submission(submission) + # # # Get JSON # report.to_json # class ReportGenerator - XARF_VERSION = "4.0.0" + SCHEMA_VERSION = "3" DEFAULT_REPORTER = { org: "phish.directory", - contact: "abuse@phish.directory", - domain: "phish.directory" + domain: "phish.directory", + email: "reports@phish.directory", + contact_name: "phish.directory Automated Reporting" }.freeze attr_reader :reporter @@ -33,11 +42,11 @@ def initialize(reporter: nil) @reporter = reporter || DEFAULT_REPORTER end - # Generate a XARF report for a Phish::Domain record + # Generate an X-ARF report for a Phish::Domain record # # @param domain [Phish::Domain] domain record # @param options [Hash] additional options - # @return [Hash] XARF v4 compliant report + # @return [Hash] X-ARF schema 3 report def generate_for_domain(domain, **options) raise ArgumentError, "Domain required" if domain.nil? @@ -49,22 +58,18 @@ def generate_for_domain(domain, **options) end build_report( - source_identifier: domain.domain, - source_type: :domain, - category: mapping[:category], - type: mapping[:type], - confidence: mapping[:confidence], + source_url: "https://#{domain.domain}", + mapping: mapping, verdict: verdict, - record: domain, **options ) end - # Generate a XARF report for a Phish::Url record + # Generate an X-ARF report for a Phish::Url record # # @param url [Phish::Url] URL record # @param options [Hash] additional options - # @return [Hash] XARF v4 compliant report + # @return [Hash] X-ARF schema 3 report def generate_for_url(url, **options) raise ArgumentError, "URL required" if url.nil? @@ -76,25 +81,20 @@ def generate_for_url(url, **options) end build_report( - source_identifier: url.domain || url.url, - source_type: :url, - category: mapping[:category], - type: mapping[:type], - confidence: mapping[:confidence], + source_url: url.url, + mapping: mapping, verdict: verdict, - record: url, - url: url.url, **options ) end - # Generate a XARF report for a Verdict with a specified source + # Generate an X-ARF report for a Verdict with a specified source # # @param verdict [Verdict] verdict record # @param source_type [Symbol] :domain or :url # @param source [String] the source identifier # @param options [Hash] additional options - # @return [Hash] XARF v4 compliant report + # @return [Hash] X-ARF schema 3 report def generate_for_verdict(verdict, source_type:, source:, **options) raise ArgumentError, "Verdict required" if verdict.nil? raise ArgumentError, "Source required" if source.blank? @@ -105,22 +105,44 @@ def generate_for_verdict(verdict, source_type:, source:, **options) return { error: "Verdict classification not reportable via XARF" } end + source_url = source_type.to_sym == :url ? source : "https://#{source}" + + build_report(source_url: source_url, mapping: mapping, verdict: verdict, **options) + end + + # Generate the document that goes out as an abuse report's xarf.json. + # + # A submission carries case context the other entry points do not have: the + # case number, the address replies thread back to, and the addresses the + # domain resolved to when the case was opened. + # + # @param submission [Report::Submission] submission record + # @param options [Hash] additional options + # @return [Hash] X-ARF schema 3 report + def generate_for_submission(submission, **options) + raise ArgumentError, "Submission required" if submission.nil? + + report_case = submission.case + payload = (submission.payload.presence || submission.build_payload).with_indifferent_access + build_report( - source_identifier: source, - source_type: source_type, - category: mapping[:category], - type: mapping[:type], - confidence: mapping[:confidence], - verdict: verdict, + source_url: payload[:url].presence || "https://#{payload[:domain] || report_case.domain_name}", + mapping: submission_mapping(payload), + source_ip: case_source_ip(report_case), + detected_at: payload[:detected_at], + sources: payload[:sources], + case_reference: payload[:case_reference] || report_case.case_number, + # Replies to this address thread back onto the case. + contact_email: report_case.email_address, **options ) end - # Generate bulk XARF reports for multiple domains + # Generate bulk X-ARF reports for multiple domains # # @param domains [Array] array of domain records # @param options [Hash] additional options - # @return [Array] array of XARF reports + # @return [Array] array of X-ARF reports def generate_bulk_for_domains(domains, **options) domains.filter_map do |domain| report = generate_for_domain(domain, **options) @@ -128,11 +150,11 @@ def generate_bulk_for_domains(domains, **options) end end - # Generate bulk XARF reports for multiple URLs + # Generate bulk X-ARF reports for multiple URLs # # @param urls [Array] array of URL records # @param options [Hash] additional options - # @return [Array] array of XARF reports + # @return [Array] array of X-ARF reports def generate_bulk_for_urls(urls, **options) urls.filter_map do |url| report = generate_for_url(url, **options) @@ -142,270 +164,112 @@ def generate_bulk_for_urls(urls, **options) # Export reports to NDJSON format (one JSON per line) # - # @param reports [Array] array of XARF reports + # @param reports [Array] array of X-ARF reports # @return [String] NDJSON formatted string def to_ndjson(reports) - reports.map { |r| r.to_json }.join("\n") + reports.map(&:to_json).join("\n") end private - def build_report(source_identifier:, source_type:, category:, type:, confidence:, verdict:, record: nil, **options) - report = { - xarf_version: XARF_VERSION, - report_id: generate_uuid, - timestamp: Time.current.iso8601, - reporter: format_contact(reporter, include_type: true), - sender: format_contact(reporter), - source_identifier: source_identifier, - category: category, - type: type, - severity: determine_severity(type, confidence), - description: build_description(type, source_identifier, verdict) + def build_report(source_url:, mapping:, verdict: nil, source_ip: nil, detected_at: nil, + sources: nil, case_reference: nil, contact_email: nil, **options) + confidence = mapping[:confidence] + source_names = format_sources(sources || verdict&.sources_list) + + { + "Version" => SCHEMA_VERSION, + "ReporterInfo" => reporter_info(contact_email), + "Disclosure" => true, + "Report" => { + "ReportClass" => mapping[:report_class], + "ReportType" => mapping[:report_type], + "Date" => format_date(detected_at || verdict&.created_at), + "SourceUrl" => source_url, + "SourceIp" => normalize_ip(source_ip), + "Ongoing" => true, + "ReporterCaseID" => case_reference, + "ReporterSeverity" => CategoryMapper.severity_for_confidence(confidence), + "ReporterNotes" => notes(confidence, source_names, case_reference, contact_email), + "Custom" => custom_fields(confidence, source_names, case_reference), + "Samples" => options[:samples].presence + }.compact } - - # Add confidence if available - report[:confidence] = confidence.round(2) if confidence - - # Add URL if provided - report[:url] = options[:url] if options[:url].present? - - # Add type-specific fields - add_phishing_fields(report, verdict, record, options) if type == "phishing" - add_fraud_fields(report, verdict, record, options) if type == "fraud" - - # Add evidence (always include, even if empty array for spec compliance) - evidence = build_evidence(verdict, record, options) - report[:evidence] = evidence - - # Add tags - tags = build_tags(verdict, record, source_type) - report[:tags] = tags if tags.any? - - # Add optional fields - add_optional_fields(report, verdict, record, options) - - report end - def format_contact(contact, include_type: false) - result = { - org: contact[:org], - contact: contact[:contact], - domain: contact[:domain] - } - result[:type] = "automated" if include_type - result + # ReporterInfo forbids properties outside this set, so nothing else goes in. + def reporter_info(contact_email) + { + "ReporterType" => "Org", + "ReporterOrg" => reporter[:org], + "ReporterOrgDomain" => reporter[:domain], + "ReporterOrgEmail" => reporter[:email], + "ReporterContactName" => reporter[:contact_name], + "ReporterContactEmail" => contact_email || reporter[:email] + }.compact end - def build_description(type, source_identifier, verdict) - confidence_text = if verdict&.confidence_score - "#{(verdict.confidence_score * 100).round}% confidence" - else - "unconfirmed" - end + def submission_mapping(payload) + mapping = CategoryMapper.to_xarf(payload[:classification]) || + CategoryMapper::CLASSIFICATION_TO_XARF["phishing"] - sources_count = verdict&.sources_list&.count || 0 - sources_text = sources_count > 0 ? "detected by #{sources_count} source#{'s' if sources_count > 1}" : "" - - case type - when "phishing" - base = "Phishing site identified at #{source_identifier}" - [ base, confidence_text, sources_text ].reject(&:blank?).join(" - ") - when "suspicious_registration" - "Suspicious domain registration: #{source_identifier} - #{confidence_text}" - when "malware" - "Malware distribution identified at #{source_identifier} - #{confidence_text}" - when "fraud" - "Fraudulent activity identified at #{source_identifier} - #{confidence_text}" - else - "Abuse report for #{source_identifier} - #{confidence_text}" - end - end - - def generate_uuid - SecureRandom.uuid + mapping.merge(confidence: payload[:confidence].to_f) end - def determine_severity(type, confidence) - # Severity based on type and confidence - # critical: immediate threat requiring urgent action - # high: significant threat - # medium: moderate threat - # low: minor or informational - base_severity = case type - when "phishing", "malware", "fraud" - confidence && confidence >= 0.8 ? "high" : "medium" - when "suspicious_registration" - "medium" - else - "low" - end + # SourceIp only accepts an IP literal. Prefer IPv4: a report is more useful + # to a host when it names the address most of the traffic reached. + def case_source_ip(report_case) + info = report_case.domain_info || {} - # Elevate to critical for high-confidence phishing/malware - if %w[phishing malware].include?(type) && confidence && confidence >= 0.95 - "critical" - else - base_severity - end + Array(info["a_records"]).first || Array(info["aaaa_records"]).first end - def add_phishing_fields(report, verdict, record, options) - # Target brand from metadata or options - target_brand = options[:target_brand] || - verdict&.metadata_hash&.dig("target_brand") - report[:target_brand] = target_brand if target_brand.present? - - # Cloned site - cloned_site = options[:cloned_site] || - verdict&.metadata_hash&.dig("cloned_site") - report[:cloned_site] = cloned_site if cloned_site.present? - - # Credential fields if known - credential_fields = options[:credential_fields] || - verdict&.metadata_hash&.dig("credential_fields") - report[:credential_fields] = credential_fields if credential_fields.present? - - # Phishing kit identification - phishing_kit = options[:phishing_kit] || - verdict&.metadata_hash&.dig("phishing_kit") - report[:phishing_kit] = phishing_kit if phishing_kit.present? - - # Lure type - lure_type = options[:lure_type] || - verdict&.metadata_hash&.dig("lure_type") - report[:lure_type] = lure_type if lure_type.present? - end + def normalize_ip(address) + return nil if address.blank? - def add_fraud_fields(report, verdict, record, options) - # Similar to phishing but with fraud-specific context - target_brand = options[:target_brand] || - verdict&.metadata_hash&.dig("target_brand") - report[:target_brand] = target_brand if target_brand.present? + IPAddr.new(address.to_s).to_s + rescue IPAddr::InvalidAddressError + nil end - # Build evidence array for XARF report - # - # XARF Evidence can include (per spec): - # - Screenshots of phishing pages (image/png, image/jpeg) - # - Email headers and content (message/rfc822, text/plain) - # - HTTP response data (application/json, text/html) - # - DNS records (application/json) - # - WHOIS data (text/plain) - # - Malware samples (application/octet-stream) - with caution - # - Log files (text/plain, application/json) - # - API responses from detection services - # - # Each evidence item should have: - # - content_type: MIME type - # - payload: Base64-encoded content - # - description: Human-readable description - # - hashes: Array of integrity hashes (sha256:xxx, sha512:xxx, md5:xxx) - # - # TODO: Momento (screenshot capturing service) will provide: - # - Screenshots of phishing pages at time of detection - # - Visual evidence for XARF reports - # - def build_evidence(verdict, record, options) - evidence = [] - - # Add detection service responses as evidence - # These are the raw results from services like VirusTotal, Google Safe Browsing, etc. - if verdict&.sources_list&.any? - sources_json = verdict.sources_list.to_json - evidence << { - type: "detection_results", - description: "Detection service responses from #{verdict.sources_list.map { |s| s['name'] || s[:name] }.compact.join(', ')}", - hash: Digest::SHA256.hexdigest(sources_json), - hash_algorithm: "sha256" - } - end - - # Add verdict metadata as evidence if present - if verdict&.metadata_hash&.any? - metadata_json = verdict.metadata_hash.to_json - evidence << { - type: "metadata", - description: "Additional detection metadata", - hash: Digest::SHA256.hexdigest(metadata_json), - hash_algorithm: "sha256" - } - end - - # Add screenshot if provided (from Momento or manual upload) - if options[:screenshot].present? - evidence << { - type: "screenshot", - description: "Screenshot of malicious content", - hash: options[:screenshot_hash], - hash_algorithm: "sha256" - }.compact - end - - # Add custom evidence items - if options[:evidence].is_a?(Array) - evidence.concat(options[:evidence]) - end - - evidence + def format_date(timestamp) + Time.parse(timestamp.to_s).utc.iso8601 + rescue ArgumentError, TypeError + Time.current.utc.iso8601 end - def build_tags(verdict, record, source_type) - tags = [] - - # Add source type tag - tags << "phishdirectory:source:#{source_type}" - - # Add classification tag - if verdict&.classification - tags << "phishdirectory:classification:#{verdict.classification}" - end - - # Add confidence level tag - if verdict&.confidence_score - confidence_level = case verdict.confidence_score - when 0.8..1.0 then "high" - when 0.5...0.8 then "medium" - else "low" + def format_sources(sources) + names = Array(sources).filter_map do |source| + if source.is_a?(Hash) + source["service"] || source[:service] || source["name"] || source[:name] + else + source.to_s.presence end - tags << "phishdirectory:confidence:#{confidence_level}" - end - - # Add TLD tag for domains - if record.respond_to?(:tld) && record.tld.present? - tags << "phishdirectory:tld:#{record.tld.name}" - end - - # Add source service tags - verdict&.sources_list&.each do |source| - source_name = source["name"] || source[:name] - tags << "phishdirectory:detected_by:#{source_name}" if source_name end - tags.uniq + names.any? ? names.join(", ") : "phish.directory aggregated threat intelligence" end - def add_optional_fields(report, verdict, record, options) - # Reporter reference ID (our internal ID) - if record&.respond_to?(:public_id) - report[:reporter_reference_id] = record.public_id - elsif verdict&.respond_to?(:public_id) - report[:reporter_reference_id] = verdict.public_id - end + def notes(confidence, source_names, case_reference, contact_email) + notes = "Phishing site detected by phish.directory with " \ + "#{confidence_percent(confidence)}% confidence. " \ + "Detection sources: #{source_names}." + notes += " Case reference: #{case_reference}." if case_reference.present? + notes += " Reply to #{contact_email} with any update on your investigation." if contact_email.present? + notes + end - # Priority based on confidence - if verdict&.confidence_score - report[:priority] = case verdict.confidence_score - when 0.9..1.0 then "high" - when 0.7...0.9 then "medium" - else "low" - end - end + # Custom only accepts string and integer values. + def custom_fields(confidence, source_names, case_reference) + { + "CaseReference" => case_reference, + "Confidence" => confidence_percent(confidence), + "DetectionSources" => source_names + }.compact + end - # Custom fields from options - if options[:reporter_custom_fields].is_a?(Hash) - report[:reporter_custom_fields] = options[:reporter_custom_fields] - end + def confidence_percent(confidence) + (confidence.to_f * 100).round end end end diff --git a/app/services/xarf/report_parser.rb b/app/services/xarf/report_parser.rb index 795e193..5dc54ef 100644 --- a/app/services/xarf/report_parser.rb +++ b/app/services/xarf/report_parser.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true module Xarf - # Parses incoming XARF v4 reports and extracts relevant data + # Parses incoming X-ARF reports and extracts relevant data. + # + # Accepts schema 3 of https://github.com/abusix/xarf, the same schema + # Xarf::ReportGenerator emits. # # Usage: # parser = Xarf::ReportParser.new(json_string_or_hash) @@ -9,25 +12,17 @@ module Xarf # result = parser.parse # # result contains normalized data for creating/updating records # else - # parser.errors # => ["Missing required field: report_id", ...] + # parser.errors # => ["Missing required field: Version", ...] # end # class ReportParser - XARF_VERSION_PATTERN = /\A4\.\d+\.\d+\z/ - UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i - - REQUIRED_FIELDS = %w[ - xarf_version - report_id - timestamp - reporter - sender - source_identifier - category - type - ].freeze - - REQUIRED_CONTACT_FIELDS = %w[org contact domain].freeze + SUPPORTED_VERSIONS = %w[3].freeze + + REQUIRED_FIELDS = %w[Version ReporterInfo Disclosure Report].freeze + REQUIRED_REPORT_FIELDS = %w[ReportClass ReportType Date].freeze + + # ReporterInfo requires these unless the reporter is a natural person. + REQUIRED_REPORTER_FIELDS = %w[ReporterOrg ReporterOrgDomain ReporterOrgEmail].freeze attr_reader :raw_data, :errors @@ -37,7 +32,7 @@ def initialize(data) @raw_data = normalize_input(data) end - # Validate the XARF report structure + # Validate the X-ARF report structure # # @return [Boolean] true if valid def valid? @@ -52,7 +47,7 @@ def parsed? @parsed end - # Parse the XARF report and extract relevant data + # Parse the X-ARF report and extract relevant data # # @return [Hash] normalized data for record creation # @raise [InvalidReportError] if report is invalid @@ -62,45 +57,42 @@ def parse @parsed = true { - report_id: raw_data["report_id"], - xarf_version: raw_data["xarf_version"], - timestamp: parse_timestamp(raw_data["timestamp"]), - category: raw_data["category"], - type: raw_data["type"], - source_identifier: raw_data["source_identifier"], - reporter: parse_contact(raw_data["reporter"]), - sender: parse_contact(raw_data["sender"]), + version: raw_data["Version"], + timestamp: parse_timestamp(report["Date"]), + report_class: report_class, + report_type: report_type, + report_subtype: report["ReportSubType"], + case_id: report["ReporterCaseID"], + severity: report["ReporterSeverity"], + notes: report["ReporterNotes"], + reporter: parse_reporter(raw_data["ReporterInfo"]), + disclosure: raw_data["Disclosure"], classification: derive_classification, confidence: derive_confidence, urls: extract_urls, domains: extract_domains, ip_addresses: extract_ip_addresses, - evidence: extract_evidence, + samples: extract_samples, metadata: extract_metadata, - tags: raw_data["tags"] || [], raw: raw_data } end - # Get the XARF category + # The Report object, which holds everything about the event itself # - # @return [String, nil] - def category - raw_data["category"] + # @return [Hash] + def report + raw_data["Report"].is_a?(Hash) ? raw_data["Report"] : {} end - # Get the XARF type - # # @return [String, nil] - def type - raw_data["type"] + def report_class + report["ReportClass"] end - # Get the source identifier (IP, domain, etc.) - # # @return [String, nil] - def source_identifier - raw_data["source_identifier"] + def report_type + report["ReportType"] end # Get URLs from the report @@ -141,243 +133,169 @@ def validate @validated = true validate_required_fields - validate_xarf_version - validate_report_id + validate_version + validate_report validate_timestamp - validate_category_and_type - validate_contacts + validate_class_and_type + validate_reporter end def validate_required_fields REQUIRED_FIELDS.each do |field| - if raw_data[field].blank? - errors << "Missing required field: #{field}" - end + # Disclosure is a boolean, so false is present but blank. + next if field == "Disclosure" && [ true, false ].include?(raw_data[field]) + + errors << "Missing required field: #{field}" if raw_data[field].blank? end end - def validate_xarf_version - version = raw_data["xarf_version"] - return if version.blank? # Already caught by required fields + def validate_version + version = raw_data["Version"] + return if version.blank? - unless version.match?(XARF_VERSION_PATTERN) - errors << "Invalid xarf_version format: expected 4.x.x" + unless SUPPORTED_VERSIONS.include?(version.to_s) + errors << "Unsupported Version: expected one of #{SUPPORTED_VERSIONS.join(', ')}" end end - def validate_report_id - report_id = raw_data["report_id"] - return if report_id.blank? + def validate_report + return errors << "Report must be an object" unless raw_data["Report"].is_a?(Hash) + + REQUIRED_REPORT_FIELDS.each do |field| + errors << "Missing required field: Report.#{field}" if report[field].blank? + end - unless report_id.match?(UUID_PATTERN) - errors << "Invalid report_id format: expected UUID v4" + # A report is anchored to an origin by either an address or a URL. + if report["SourceIp"].blank? && report["SourceUrl"].blank? + errors << "Report requires either SourceIp or SourceUrl" end end def validate_timestamp - timestamp = raw_data["timestamp"] + timestamp = report["Date"] return if timestamp.blank? Time.iso8601(timestamp) rescue ArgumentError - errors << "Invalid timestamp format: expected ISO 8601" + errors << "Invalid Report.Date format: expected ISO 8601" end - def validate_category_and_type - category = raw_data["category"] - type = raw_data["type"] + def validate_class_and_type + return if report_class.blank? || report_type.blank? - return if category.blank? || type.blank? - - unless CategoryMapper.valid_xarf_category?(category) - errors << "Invalid category: #{category}" + unless CategoryMapper.valid_report_class?(report_class) + errors << "Invalid ReportClass: #{report_class}" end - unless CategoryMapper.valid_xarf_type?(type) - errors << "Invalid type: #{type}" + unless CategoryMapper.valid_report_type?(report_type) + errors << "Invalid ReportType: #{report_type}" + return end - expected_category = CategoryMapper.category_for_type(type) - if expected_category && expected_category != category - errors << "Type '#{type}' does not belong to category '#{category}'" + unless CategoryMapper.type_in_class?(report_class, report_type) + errors << "ReportType '#{report_type}' does not belong to ReportClass '#{report_class}'" end end - def validate_contacts - %w[reporter sender].each do |contact_type| - contact = raw_data[contact_type] - next if contact.blank? - - unless contact.is_a?(Hash) - errors << "#{contact_type} must be an object" - next - end - - REQUIRED_CONTACT_FIELDS.each do |field| - if contact[field].blank? - errors << "Missing #{contact_type}.#{field}" - end - end + def validate_reporter + reporter = raw_data["ReporterInfo"] + return if reporter.blank? + + return errors << "ReporterInfo must be an object" unless reporter.is_a?(Hash) + + # Contact details are optional when the reporter is a natural person. + return if reporter["ReporterType"] == "Person" + + REQUIRED_REPORTER_FIELDS.each do |field| + errors << "Missing ReporterInfo.#{field}" if reporter[field].blank? end end def parse_timestamp(timestamp_str) return nil if timestamp_str.blank? + Time.iso8601(timestamp_str) rescue ArgumentError nil end - def parse_contact(contact) - return nil if contact.blank? + def parse_reporter(reporter) + return nil if reporter.blank? { - organization: contact["org"], - email: contact["contact"], - domain: contact["domain"] - } + type: reporter["ReporterType"], + organization: reporter["ReporterOrg"], + domain: reporter["ReporterOrgDomain"], + email: reporter["ReporterOrgEmail"], + contact_name: reporter["ReporterContactName"], + contact_email: reporter["ReporterContactEmail"], + contact_phone: reporter["ReporterContactPhone"] + }.compact end def derive_classification - CategoryMapper.from_xarf(raw_data["category"], raw_data["type"]) + CategoryMapper.from_xarf(report_class, report_type) end def derive_confidence - base_confidence = CategoryMapper.confidence_for_type(raw_data["type"]) - - # Adjust based on report confidence if provided - if raw_data["confidence"].present? - report_confidence = raw_data["confidence"].to_f.clamp(0.0, 1.0) - # Weighted average: 70% type confidence, 30% report confidence - (base_confidence * 0.7) + (report_confidence * 0.3) - else - base_confidence - end + CategoryMapper.confidence_for_type(report_type) end def extract_urls urls = [] - - # Direct URL field - urls << raw_data["url"] if raw_data["url"].present? - - # Redirect chain - if raw_data["redirect_chain"].is_a?(Array) - urls.concat(raw_data["redirect_chain"]) - end - - # Submission URL (for phishing) - urls << raw_data["submission_url"] if raw_data["submission_url"].present? - - # From evidence items - evidence_items = raw_data["evidence"] || [] - evidence_items.each do |item| - if item["content_type"]&.include?("url") && item["payload"].present? - decoded = decode_payload(item["payload"]) - urls << decoded if decoded.present? && decoded.match?(%r{\Ahttps?://}) - end - end - + urls << report["SourceUrl"] if report["SourceUrl"].present? urls.compact.uniq end def extract_domains - domains = [] - - # From source_identifier if it's a domain - source = raw_data["source_identifier"] - if source.present? && !ip_address?(source) - domains << normalize_domain(source) - end - - # From cloned_site field (target of phishing) - domains << raw_data["cloned_site"] if raw_data["cloned_site"].present? - - # From target_brand (might be a domain) - target = raw_data["target_brand"] - if target.present? && target.include?(".") - domains << normalize_domain(target) - end - - # Extract domains from URLs - urls.each do |url| - domain = extract_domain_from_url(url) - domains << domain if domain.present? - end - - domains.compact.uniq + extract_urls.filter_map { |url| extract_domain_from_url(url) }.uniq end def extract_ip_addresses - ips = [] - - source = raw_data["source_identifier"] - ips << source if source.present? && ip_address?(source) - - # From evidence or additional fields - if raw_data["additional_ip_addresses"].is_a?(Array) - raw_data["additional_ip_addresses"].each do |ip| - ips << ip if ip_address?(ip) - end - end - - ips.compact.uniq + [ report["SourceIp"], report["DestinationIp"], report["AttackerIp"] ] + .compact_blank + .select { |address| ip_address?(address) } + .uniq end - def extract_evidence - evidence_items = raw_data["evidence"] || [] + def extract_samples + Array(report["Samples"]).filter_map do |sample| + next unless sample.is_a?(Hash) - evidence_items.map do |item| { - content_type: item["content_type"], - description: item["description"], - hashes: item["hashes"] || [], - payload_size: item["payload"]&.length - } + content_type: sample["ContentType"], + description: sample["Description"], + base64_encoded: sample["Base64Encoded"], + file_name: sample["FileName"], + payload_size: sample["Payload"]&.length + }.compact end end def extract_metadata { - target_brand: raw_data["target_brand"], - cloned_site: raw_data["cloned_site"], - phishing_kit: raw_data["phishing_kit"], - credential_fields: raw_data["credential_fields"], - lure_type: raw_data["lure_type"], - detection_evasion: raw_data["detection_evasion"], - reporter_reference_id: raw_data["reporter_reference_id"], - priority: raw_data["priority"], - legacy_xarf_version: raw_data["legacy_xarf_version"] + report_subtype: report["ReportSubType"], + case_id: report["ReporterCaseID"], + severity: report["ReporterSeverity"], + ongoing: report["Ongoing"], + threat_actor: report["ThreatActor"], + source_port: report["SourcePort"], + asn: report["ASN"], + custom: report["Custom"] }.compact end - def decode_payload(payload) - Base64.decode64(payload) - rescue StandardError - nil - end - def ip_address?(str) return false if str.blank? - # IPv4 - return true if str.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/) - - # IPv6 (simplified check) - str.include?(":") && str.match?(/\A[0-9a-f:]+\z/i) - end - - def normalize_domain(domain) - domain = domain.to_s.strip.downcase - domain = domain.sub(%r{\Ahttps?://}, "") - domain = domain.split("/").first - domain = domain.split(":").first - domain + IPAddr.new(str.to_s) + true + rescue IPAddr::InvalidAddressError + false end def extract_domain_from_url(url) - uri = URI.parse(url) - uri.host&.downcase + URI.parse(url).host&.downcase rescue URI::InvalidURIError nil end diff --git a/app/views/admin/report/abuse_contacts/_form.html.erb b/app/views/admin/report/abuse_contacts/_form.html.erb index 06bd8cf..524c2bd 100644 --- a/app/views/admin/report/abuse_contacts/_form.html.erb +++ b/app/views/admin/report/abuse_contacts/_form.html.erb @@ -64,6 +64,12 @@ +
+ <%= f.check_box :accepts_xarf, class: "w-4 h-4 rounded border-surface-300 bg-surface-200 text-accent focus:ring-accent focus:ring-offset-0" %> + <%= f.label :accepts_xarf, "Send X-ARF attachment", class: "ml-2 text-sm text-surface-700" %> + For mailboxes processed by automated tooling that requires a machine readable report. +
+
<%= f.label :notes, class: "block text-xs font-medium text-surface-600 mb-1.5" %> <%= f.text_area :notes, rows: 3, class: "w-full px-3 py-2.5 bg-surface-200 border border-surface-300/50 rounded-md text-surface-900 placeholder-surface-500 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors resize-none", placeholder: "Additional notes..." %> diff --git a/app/views/reports/case_report.html.erb b/app/views/reports/case_report.html.erb index c7b9df1..4bcada4 100644 --- a/app/views/reports/case_report.html.erb +++ b/app/views/reports/case_report.html.erb @@ -112,6 +112,18 @@ <% end %> + <% if @domain_info["aaaa_records"].present? %> + + AAAA Records + +
    + <% @domain_info["aaaa_records"].each do |ip| %> +
  • <%= ip %>
  • + <% end %> +
+ + + <% end %> <% end %> diff --git a/config/recurring.yml b/config/recurring.yml index c5b5acf..b166c25 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -95,6 +95,12 @@ phishdestroy_sync: queue: maintenance schedule: every 6 hours at minute 30 +# DigitalOcean IP ranges - decide which phishing domains DigitalOcean hosts +digitalocean_range_sync: + class: DigitalOceanRangeSyncJob + queue: maintenance + schedule: every sunday at 5am + # Blazer checks - run scheduled query checks blazer_run_checks_5_minutes: command: "Blazer.run_checks(schedule: '5 minutes')" diff --git a/db/migrate/20260907120001_add_accepts_xarf_to_report_abuse_contacts.rb b/db/migrate/20260907120001_add_accepts_xarf_to_report_abuse_contacts.rb new file mode 100644 index 0000000..7116fd7 --- /dev/null +++ b/db/migrate/20260907120001_add_accepts_xarf_to_report_abuse_contacts.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Some abuse desks process their mailbox with automated tooling and drop +# anything that is only prose. DigitalOcean is the first of these: it asks for +# an X-ARF (https://github.com/abusix/xarf) attachment on every report. +# +# Contacts with this flag get the X-ARF envelope from Report::AbuseReportMailer +# instead of the plain HTML report. Everyone else is unaffected. +class AddAcceptsXarfToReportAbuseContacts < ActiveRecord::Migration[8.1] + def change + add_column :report_abuse_contacts, :accepts_xarf, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260907120002_add_aaaa_records_to_report_domain_lookups.rb b/db/migrate/20260907120002_add_aaaa_records_to_report_domain_lookups.rb new file mode 100644 index 0000000..5298a64 --- /dev/null +++ b/db/migrate/20260907120002_add_aaaa_records_to_report_domain_lookups.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Hosting providers are matched by the addresses a domain resolves to, and +# DigitalOcean publishes IPv6 allocations alongside its IPv4 ones. Only A +# records were stored, so an IPv6-only host was never matched and its abuse +# desk never heard about the site. +# +# Kept separate from a_records rather than mixed into it: the column name says +# A records, and the case report renders the two lists under their own headings. +class AddAaaaRecordsToReportDomainLookups < ActiveRecord::Migration[8.1] + def change + add_column :report_domain_lookups, :aaaa_records, :jsonb, default: [] + end +end diff --git a/db/seeds/abuse_contacts.rb b/db/seeds/abuse_contacts.rb index 43f5ef3..0ac8bb8 100644 --- a/db/seeds/abuse_contacts.rb +++ b/db/seeds/abuse_contacts.rb @@ -39,6 +39,7 @@ class AbuseContacts # Hosting Providers (priority: 30) { name: "Amazon AWS", contact_type: :hosting, method: :email, email: "abuse@amazonaws.com", priority: 30 }, { name: "Deno Deploy", contact_type: :hosting, method: :email, email: "deploy@deno.com", priority: 30, notes: "Handles deno.dev domains." }, + { name: "DigitalOcean", contact_type: :hosting, method: :email, email: "abuse@digitalocean.com", priority: 30, accepts_xarf: true, nameserver_patterns: [ "ns1.digitalocean.com", "ns2.digitalocean.com", "ns3.digitalocean.com" ], notes: "The mailbox is processed by automated tooling that only accepts X-ARF reports, so accepts_xarf must stay on. DigitalOceanRangeSyncJob keeps ip_ranges current from https://digitalocean.com/geo/google.csv - do not edit them by hand." }, { name: "ESITED", contact_type: :hosting, method: :email, email: "net-abuse@esited.com", priority: 30 }, { name: "Google Cloud", contact_type: :hosting, method: :email, email: "google-cloud-compliance@google.com", priority: 30 }, { name: "IQWeb FZ-LLC", contact_type: :hosting, method: :email, email: "abuse@iqweb.io", priority: 30 }, diff --git a/test/mailers/report/abuse_report_mailer_test.rb b/test/mailers/report/abuse_report_mailer_test.rb new file mode 100644 index 0000000..fe31eb6 --- /dev/null +++ b/test/mailers/report/abuse_report_mailer_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "test_helper" + +# DigitalOcean processes its abuse mailbox with automated tooling that only +# accepts an X-ARF attachment. The envelope follows the SMTP binding in +# https://github.com/abusix/xarf, so the structure is asserted here part by +# part: tooling on the other end reads the MIME tree, not the prose. +class Report::AbuseReportMailerTest < ActionMailer::TestCase + def deliver_to(contact, report_case: nil) + report_case ||= create_test_report_case(domain_info: { "a_records" => [ "24.144.65.10" ] }) + + Report::AbuseReportMailer.with( + submission: create_test_submission(report_case, contact), + case: report_case, + contact: contact + ).abuse_report + end + + test "an X-ARF contact receives a feedback report envelope" do + mail = deliver_to(create_test_abuse_contact(accepts_xarf: true)) + + assert_equal "multipart/report", mail.mime_type + assert_equal "feedback-report", mail.content_type_parameters["report-type"] + end + + test "the envelope holds the three parts the binding requires, in order" do + mail = deliver_to(create_test_abuse_contact(accepts_xarf: true)) + + assert_equal 3, mail.parts.size + assert_equal "text/plain", mail.parts[0].mime_type + assert_equal "message/feedback-report", mail.parts[1].mime_type + assert_equal "application/json", mail.parts[2].mime_type + end + + test "the feedback report part tells an ARF parser to expect X-ARF" do + mail = deliver_to(create_test_abuse_contact(accepts_xarf: true)) + body = mail.parts[1].body.decoded + + assert_includes body, "Feedback-Type: xarf" + assert_includes body, "Version: 1" + assert_includes body, "User-Agent: phish.directory/" + end + + test "the attachment is xarf.json and parses as an X-ARF document" do + mail = deliver_to(create_test_abuse_contact(accepts_xarf: true)) + attachment = mail.attachments.find { |part| part.filename == "xarf.json" } + + assert attachment, "expected an xarf.json attachment" + + report = JSON.parse(attachment.body.decoded) + + assert_equal "3", report["Version"] + assert_equal "Phishing", report.dig("Report", "ReportType") + assert_equal "24.144.65.10", report.dig("Report", "SourceIp") + end + + test "the human readable part still describes the report" do + report_case = create_test_report_case + mail = deliver_to(create_test_abuse_contact(accepts_xarf: true), report_case: report_case) + + assert_includes mail.parts[0].body.decoded, report_case.domain_name + end + + test "the case address stays on the report so replies thread" do + report_case = create_test_report_case + contact = create_test_abuse_contact(accepts_xarf: true, email: "abuse@digitalocean.example") + mail = deliver_to(contact, report_case: report_case) + + assert_equal [ "abuse@digitalocean.example" ], mail.to + assert_equal [ report_case.email_address ], mail.cc + assert_includes mail.subject, report_case.case_number + end + + test "a contact that did not ask for X-ARF gets the report unchanged" do + mail = deliver_to(create_test_abuse_contact(accepts_xarf: false)) + + assert_equal "multipart/alternative", mail.mime_type + assert_empty mail.attachments + assert mail.html_part, "expected the HTML report to still be sent" + end + + test "both kinds of report are deliverable" do + xarf = deliver_to(create_test_abuse_contact(accepts_xarf: true)) + plain = deliver_to(create_test_abuse_contact(accepts_xarf: false)) + + assert_nothing_raised do + xarf.deliver_now + plain.deliver_now + end + assert_equal 2, ActionMailer::Base.deliveries.size + end +end diff --git a/test/models/report/abuse_contact_test.rb b/test/models/report/abuse_contact_test.rb new file mode 100644 index 0000000..677dc67 --- /dev/null +++ b/test/models/report/abuse_contact_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_helper" + +# Hosting used to be matched on nameserver patterns alone. A phishing site on a +# DigitalOcean droplet keeps its registrar's nameservers, so that never found +# the provider serving the page. Matching the addresses the domain resolves to +# is what does. +class Report::AbuseContactTest < ActiveSupport::TestCase + test "a contact is found when its ranges cover the address" do + contact = create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + + assert_equal contact, Report::AbuseContact.find_for_ip([ "24.144.65.10" ]) + end + + test "a contact outside the address range is not found" do + create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + + assert_nil Report::AbuseContact.find_for_ip([ "8.8.8.8" ]) + end + + test "any one of several addresses is enough to match" do + contact = create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + + assert_equal contact, Report::AbuseContact.find_for_ip([ "8.8.8.8", "24.144.65.10" ]) + end + + test "an inactive contact is never matched" do + create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ], active: false) + + assert_nil Report::AbuseContact.find_for_ip([ "24.144.65.10" ]) + end + + test "the lowest priority number wins when ranges overlap" do + create_test_abuse_contact(ip_ranges: [ "24.144.0.0/16" ], priority: 40) + preferred = create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ], priority: 10) + + assert_equal preferred, Report::AbuseContact.find_for_ip([ "24.144.65.10" ]) + end + + test "no addresses means no match" do + create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + + assert_nil Report::AbuseContact.find_for_ip([]) + assert_nil Report::AbuseContact.find_for_ip(nil) + end + + test "an unparseable address is skipped instead of raising" do + create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + + assert_nil Report::AbuseContact.find_for_ip([ "not-an-ip" ]) + end + + test "an unparseable range is skipped instead of raising" do + contact = create_test_abuse_contact(ip_ranges: [ "garbage", "24.144.64.0/22" ]) + + assert_equal contact, Report::AbuseContact.find_for_ip([ "24.144.65.10" ]) + end + + test "an IPv6 address does not match an IPv4 range" do + create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + + assert_nil Report::AbuseContact.find_for_ip([ "2604:a880::1" ]) + end + + test "an IPv6 address matches an IPv6 range" do + contact = create_test_abuse_contact(ip_ranges: [ "2604:a880::/32" ]) + + assert_equal contact, Report::AbuseContact.find_for_ip([ "2604:a880::1" ]) + end +end diff --git a/test/models/report/domain_lookup_test.rb b/test/models/report/domain_lookup_test.rb new file mode 100644 index 0000000..54f8f77 --- /dev/null +++ b/test/models/report/domain_lookup_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "test_helper" + +class Report::DomainLookupTest < ActiveSupport::TestCase + def lookup_for(attrs = {}) + Report::DomainLookup.create!( + { domain: "bad-#{SecureRandom.hex(4)}.com" }.merge(attrs) + ) + end + + test "a nameserver match still wins" do + host = create_test_abuse_contact(nameserver_patterns: [ "ns1.examplehost.com" ]) + lookup = lookup_for(nameservers: [ "ns1.examplehost.com" ]) + + lookup.match_contacts! + + assert_equal host, lookup.matched_hosting_contact + end + + test "hosting falls back to the addresses the domain resolves to" do + host = create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + lookup = lookup_for(nameservers: [ "ns1.somewhere-else.com" ], a_records: [ "24.144.65.10" ]) + + lookup.match_contacts! + + assert_equal host, lookup.matched_hosting_contact + end + + test "the matched host is recorded as the hosting provider" do + host = create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + lookup = lookup_for(a_records: [ "24.144.65.10" ]) + + lookup.match_contacts! + + assert_equal host.name, lookup.reload.hosting_provider + end + + test "no match leaves the hosting contact empty" do + create_test_abuse_contact(ip_ranges: [ "24.144.64.0/22" ]) + lookup = lookup_for(a_records: [ "8.8.8.8" ]) + + lookup.match_contacts! + + assert_nil lookup.matched_hosting_contact + end + + test "an IPv6-only host is matched on its AAAA records" do + host = create_test_abuse_contact(ip_ranges: [ "2604:a880::/32" ]) + lookup = lookup_for(a_records: [], aaaa_records: [ "2604:a880::1" ]) + + lookup.match_contacts! + + assert_equal host, lookup.matched_hosting_contact + end + + test "both address families are offered to the matcher" do + lookup = lookup_for(a_records: [ "24.144.65.10" ], aaaa_records: [ "2604:a880::1" ]) + + assert_equal [ "24.144.65.10", "2604:a880::1" ], lookup.resolved_addresses + end + + test "the resolved addresses travel with the case summary" do + lookup = lookup_for(a_records: [ "24.144.65.10" ], aaaa_records: [ "2604:a880::1" ]) + summary = lookup.to_summary + + assert_equal [ "24.144.65.10" ], summary[:a_records] + assert_equal [ "2604:a880::1" ], summary[:aaaa_records] + end +end diff --git a/test/services/report/digital_ocean_range_service_test.rb b/test/services/report/digital_ocean_range_service_test.rb new file mode 100644 index 0000000..bae5783 --- /dev/null +++ b/test/services/report/digital_ocean_range_service_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "test_helper" + +# The pipeline only reports a domain to DigitalOcean when the domain resolves +# into DigitalOcean address space, so the range list is what makes the contact +# reachable at all. A stale or empty list silently stops those reports. +class Report::DigitalOceanRangeServiceTest < ActiveSupport::TestCase + CSV_BODY = <<~CSV + 5.101.96.0/21,NL,NL-NH,Amsterdam,1098 XH + 24.144.64.0/22,US,US-NJ,North Bergen,07047 + 2604:a880::/32,US,US-NJ,North Bergen,07047 + CSV + + setup do + @contact = create_test_abuse_contact( + name: Report::DigitalOceanRangeService::CONTACT_NAME, + email: "abuse@digitalocean.com", + accepts_xarf: true + ) + end + + def stub_ranges(body:, status: 200) + stub_request(:get, Report::DigitalOceanRangeService::RANGES_URL) + .to_return(status: status, body: body, headers: { "Content-Type" => "text/csv" }) + end + + test "the published ranges land on the contact" do + stub_ranges(body: CSV_BODY) + + result = Report::DigitalOceanRangeService.new.sync + + assert result[:success] + assert_equal 3, result[:ranges] + assert_equal [ "5.101.96.0/21", "24.144.64.0/22", "2604:a880::/32" ], @contact.reload.ip_ranges + end + + test "a synced range makes a domain in that space match the contact" do + stub_ranges(body: CSV_BODY) + Report::DigitalOceanRangeService.new.sync + + assert_equal @contact, Report::AbuseContact.find_for_ip([ "24.144.65.10" ]) + end + + test "rows that are not CIDR blocks are dropped rather than stored" do + stub_ranges(body: "not-a-range,US\n24.144.64.0/22,US\n\n") + + result = Report::DigitalOceanRangeService.new.sync + + assert result[:success] + assert_equal [ "24.144.64.0/22" ], @contact.reload.ip_ranges + end + + test "an empty list leaves the existing ranges alone" do + @contact.update!(ip_ranges: [ "24.144.64.0/22" ]) + stub_ranges(body: "\n") + + result = Report::DigitalOceanRangeService.new.sync + + assert_not result[:success] + assert_equal [ "24.144.64.0/22" ], @contact.reload.ip_ranges + end + + test "a failed fetch is reported instead of raising" do + stub_ranges(body: "boom", status: 500) + + result = Report::DigitalOceanRangeService.new.sync + + assert_not result[:success] + assert result[:error].present? + end + + test "a missing contact is reported instead of raising" do + @contact.destroy! + stub_ranges(body: CSV_BODY) + + result = Report::DigitalOceanRangeService.new.sync + + assert_not result[:success] + assert_includes result[:error], "not seeded" + end +end diff --git a/test/services/xarf/category_mapper_test.rb b/test/services/xarf/category_mapper_test.rb new file mode 100644 index 0000000..5eca44c --- /dev/null +++ b/test/services/xarf/category_mapper_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "test_helper" + +# The taxonomy used to be an invented "XARF v4" of snake_case categories and +# types that appears in no published schema. Schema 3 is the newest abusix +# publishes, and it is a ReportClass plus a ReportType. +class Xarf::CategoryMapperTest < ActiveSupport::TestCase + test "every class in the taxonomy is one the schema defines" do + assert_equal %w[Content Activity Vulnerability], Xarf::CategoryMapper::REPORT_CLASSES + end + + test "phishing maps onto the content class" do + assert_equal( + { report_class: "Content", report_type: "Phishing" }, + Xarf::CategoryMapper.to_xarf("phishing") + ) + end + + test "an unconfirmed site is still reported, with the class the schema has" do + mapping = Xarf::CategoryMapper.to_xarf("suspicious") + + assert_equal "Content", mapping[:report_class] + assert_equal "Phishing", mapping[:report_type] + end + + test "clean, unknown and protected domains are never reported" do + %w[clean unknown protected].each do |classification| + assert_nil Xarf::CategoryMapper.to_xarf(classification) + assert_not Xarf::CategoryMapper.reportable?(classification) + end + end + + test "an incoming report maps back onto a classification" do + assert_equal "phishing", Xarf::CategoryMapper.from_xarf("Content", "Phishing") + assert_equal "phishing", Xarf::CategoryMapper.from_xarf("Content", "Malware") + assert_equal "suspicious", Xarf::CategoryMapper.from_xarf("Activity", "Spam") + end + + test "a type outside what this directory classifies maps to nothing" do + assert_nil Xarf::CategoryMapper.from_xarf("Content", "Copyright") + end + + test "the invented v4 vocabulary is rejected" do + assert_not Xarf::CategoryMapper.valid_report_class?("content") + assert_not Xarf::CategoryMapper.valid_report_type?("suspicious_registration") + assert_not Xarf::CategoryMapper.valid_report_type?("brand_infringement") + assert_nil Xarf::CategoryMapper.from_xarf("content", "phishing") + end + + test "a type is checked against the class it was sent under" do + assert Xarf::CategoryMapper.type_in_class?("Content", "Phishing") + assert_not Xarf::CategoryMapper.type_in_class?("Vulnerability", "Phishing") + end + + test "Malware is valid under both the classes the schema allows it in" do + assert Xarf::CategoryMapper.type_in_class?("Content", "Malware") + assert Xarf::CategoryMapper.type_in_class?("Activity", "Malware") + end + + test "severity uses the closed enum the schema defines" do + assert_equal "high", Xarf::CategoryMapper.severity_for_confidence(0.95) + assert_equal "medium", Xarf::CategoryMapper.severity_for_confidence(0.7) + assert_equal "low", Xarf::CategoryMapper.severity_for_confidence(0.2) + assert_equal "low", Xarf::CategoryMapper.severity_for_confidence(nil) + end + + test "a verdict maps with its confidence carried through" do + verdict = Verdict.create!(classification: "phishing", confidence_score: 0.91) + mapping = Xarf::CategoryMapper.map_verdict(verdict) + + assert mapping[:reportable] + assert_equal "Phishing", mapping[:report_type] + assert_in_delta 0.91, mapping[:confidence] + end + + test "a missing verdict is not reportable" do + assert_not Xarf::CategoryMapper.map_verdict(nil)[:reportable] + end +end diff --git a/test/services/xarf/report_generator_test.rb b/test/services/xarf/report_generator_test.rb new file mode 100644 index 0000000..3f92131 --- /dev/null +++ b/test/services/xarf/report_generator_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "test_helper" + +# DigitalOcean parses the xarf.json attachment with automated tooling and drops +# the report if it does not validate, so the document has to match schema 3 of +# https://github.com/abusix/xarf exactly. The admin UI renders the same +# document, so there is one representation rather than two that disagree. +class Xarf::ReportGeneratorTest < ActiveSupport::TestCase + setup do + @contact = create_test_abuse_contact(accepts_xarf: true) + @generator = Xarf::ReportGenerator.new + end + + def build(report_case) + @generator.generate_for_submission(create_test_submission(report_case, @contact)) + end + + # ---------------------------------------------------------------- envelope + + test "the document carries the fields the schema requires" do + report = build(create_test_report_case) + + assert_equal "3", report["Version"] + assert_equal true, report["Disclosure"] + assert report["ReporterInfo"].present? + assert report["Report"].present? + end + + test "the version is the newest published schema, not an invented one" do + assert_equal "3", Xarf::ReportGenerator::SCHEMA_VERSION + assert_includes Xarf::ReportParser::SUPPORTED_VERSIONS, "3" + end + + test "a phishing report is classed as content" do + report = build(create_test_report_case)["Report"] + + assert_equal "Content", report["ReportClass"] + assert_equal "Phishing", report["ReportType"] + assert_equal true, report["Ongoing"] + end + + test "ReporterInfo carries only keys the schema allows" do + allowed = %w[ + ReporterType ReporterOrg ReporterOrgDomain ReporterOrgEmail + ReporterOrgAddress ReporterContactEmail ReporterContactName + ReporterContactPhone + ] + + reporter = build(create_test_report_case)["ReporterInfo"] + + assert_empty reporter.keys - allowed + assert_equal "phish.directory", reporter["ReporterOrg"] + assert_equal "phish.directory", reporter["ReporterOrgDomain"] + assert reporter["ReporterOrgEmail"].present? + end + + test "replies to the reporter contact thread back onto the case" do + report_case = create_test_report_case + reporter = build(report_case)["ReporterInfo"] + + assert_equal report_case.email_address, reporter["ReporterContactEmail"] + end + + # ------------------------------------------------------------------ source + + test "a domain-only report still carries a SourceUrl" do + report_case = create_test_report_case + report = build(report_case)["Report"] + + assert_equal "https://#{report_case.domain_name}", report["SourceUrl"] + end + + test "SourceIp comes from the addresses the domain resolves to" do + report_case = create_test_report_case(domain_info: { "a_records" => [ "24.144.65.10" ] }) + + assert_equal "24.144.65.10", build(report_case)["Report"]["SourceIp"] + end + + test "an IPv6-only host still gets a SourceIp" do + report_case = create_test_report_case(domain_info: { "aaaa_records" => [ "2604:a880::1" ] }) + + assert_equal "2604:a880::1", build(report_case)["Report"]["SourceIp"] + end + + test "IPv4 is preferred when the domain resolves to both" do + report_case = create_test_report_case( + domain_info: { "a_records" => [ "24.144.65.10" ], "aaaa_records" => [ "2604:a880::1" ] } + ) + + assert_equal "24.144.65.10", build(report_case)["Report"]["SourceIp"] + end + + test "SourceIp is omitted rather than sent empty when nothing resolved" do + report = build(create_test_report_case(domain_info: { "a_records" => [] }))["Report"] + + assert_not report.key?("SourceIp") + end + + test "an unparseable address is dropped instead of failing validation" do + report_case = create_test_report_case(domain_info: { "a_records" => [ "not-an-ip" ] }) + + assert_not build(report_case)["Report"].key?("SourceIp") + end + + # ------------------------------------------------------------------ fields + + test "the case number goes out as the reporter case id" do + report_case = create_test_report_case + report = build(report_case)["Report"] + + assert_equal report_case.case_number, report["ReporterCaseID"] + assert_equal report_case.case_number, report["Custom"]["CaseReference"] + end + + test "confidence maps onto the severity values the schema allows" do + assert_equal "high", build(create_test_report_case(confidence: 0.95))["Report"]["ReporterSeverity"] + assert_equal "medium", build(create_test_report_case(confidence: 0.75))["Report"]["ReporterSeverity"] + assert_equal "low", build(create_test_report_case(confidence: 0.5))["Report"]["ReporterSeverity"] + end + + test "Custom holds only strings and integers, as the schema demands" do + custom = build(create_test_report_case)["Report"]["Custom"] + + assert custom.any? + custom.each_value { |value| assert value.is_a?(String) || value.is_a?(Integer) } + end + + test "the detection sources reach the abuse desk" do + report_case = create_test_report_case( + sources: [ { "service" => "VirusTotal" }, { "service" => "OpenPhish" } ] + ) + report = build(report_case)["Report"] + + assert_equal "VirusTotal, OpenPhish", report["Custom"]["DetectionSources"] + assert_includes report["ReporterNotes"], "VirusTotal" + end + + test "sources recorded under a name key are read too" do + report_case = create_test_report_case(sources: [ { "name" => "FishFish" } ]) + + assert_equal "FishFish", build(report_case)["Report"]["Custom"]["DetectionSources"] + end + + test "Date is an ISO 8601 timestamp" do + date = build(create_test_report_case)["Report"]["Date"] + + assert_nothing_raised { Time.iso8601(date) } + end + + test "a nil submission is rejected outright" do + assert_raises(ArgumentError) { @generator.generate_for_submission(nil) } + end + + # ------------------------------------------------- domain and url entrypoints + + test "a domain report validates as the same schema the mailer sends" do + domain = Phish::Domain.create!(domain: "bad-#{SecureRandom.hex(4)}.com") + domain.update!(verdict: Verdict.create!(classification: "phishing", confidence_score: 0.95)) + + report = @generator.generate_for_domain(domain) + + assert_equal "3", report["Version"] + assert_equal "Content", report.dig("Report", "ReportClass") + assert_equal "https://#{domain.domain}", report.dig("Report", "SourceUrl") + end + + test "a URL report reports the full URL as the source" do + url = Phish::Url.create!(url: "https://bad-#{SecureRandom.hex(4)}.com/login") + url.update!(verdict: Verdict.create!(classification: "phishing", confidence_score: 0.95)) + + report = @generator.generate_for_url(url) + + assert_equal url.url, report.dig("Report", "SourceUrl") + end + + test "a clean domain is not reportable" do + domain = Phish::Domain.create!(domain: "good-#{SecureRandom.hex(4)}.com") + domain.update!(verdict: Verdict.create!(classification: "clean", confidence_score: 0.99)) + + assert Xarf::ReportGenerator.new.generate_for_domain(domain)[:error].present? + end + + test "the generated document round-trips through the parser" do + report = build(create_test_report_case) + parser = Xarf::ReportParser.new(JSON.generate(report)) + + assert parser.valid?, parser.errors.join(", ") + assert_equal "phishing", parser.parse[:classification] + end +end diff --git a/test/services/xarf/report_parser_test.rb b/test/services/xarf/report_parser_test.rb new file mode 100644 index 0000000..bcdc6a4 --- /dev/null +++ b/test/services/xarf/report_parser_test.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "test_helper" + +class Xarf::ReportParserTest < ActiveSupport::TestCase + # Taken from samples/positive/3/phishing_sample.json in abusix/xarf. + def valid_report(overrides = {}) + { + "Version" => "3", + "ReporterInfo" => { + "ReporterOrg" => "ExampleOrg", + "ReporterOrgDomain" => "example.com", + "ReporterOrgEmail" => "reports@example.com" + }, + "Disclosure" => true, + "Report" => { + "ReportClass" => "Content", + "ReportType" => "Phishing", + "Date" => "2018-02-05T14:17:10Z", + "SourceIp" => "192.0.2.55", + "SourceUrl" => "http://phish.example.org/index.html", + "Ongoing" => true + } + }.deep_merge(overrides) + end + + test "the published sample parses" do + parser = Xarf::ReportParser.new(valid_report) + + assert parser.valid?, parser.errors.join(", ") + end + + test "a JSON string parses the same as a hash" do + parser = Xarf::ReportParser.new(JSON.generate(valid_report)) + + assert parser.valid?, parser.errors.join(", ") + assert_equal "Phishing", parser.report_type + end + + test "the parsed report carries the fields callers need" do + result = Xarf::ReportParser.new(valid_report).parse + + assert_equal "3", result[:version] + assert_equal "Content", result[:report_class] + assert_equal "Phishing", result[:report_type] + assert_equal "phishing", result[:classification] + assert_equal [ "http://phish.example.org/index.html" ], result[:urls] + assert_equal [ "phish.example.org" ], result[:domains] + assert_equal [ "192.0.2.55" ], result[:ip_addresses] + assert_equal "ExampleOrg", result.dig(:reporter, :organization) + end + + test "an older schema version is refused" do + parser = Xarf::ReportParser.new(valid_report("Version" => "2")) + + assert_not parser.valid? + assert(parser.errors.any? { |e| e.include?("Unsupported Version") }) + end + + test "the invented v4 shape is refused rather than half-parsed" do + parser = Xarf::ReportParser.new( + "xarf_version" => "4.0.0", + "report_id" => SecureRandom.uuid, + "category" => "content", + "type" => "phishing" + ) + + assert_not parser.valid? + end + + test "a report with neither an address nor a URL is refused" do + report = valid_report + report["Report"].delete("SourceIp") + report["Report"].delete("SourceUrl") + + parser = Xarf::ReportParser.new(report) + + assert_not parser.valid? + assert(parser.errors.any? { |e| e.include?("SourceIp or SourceUrl") }) + end + + test "a type sent under the wrong class is refused" do + parser = Xarf::ReportParser.new(valid_report("Report" => { "ReportClass" => "Vulnerability" })) + + assert_not parser.valid? + assert(parser.errors.any? { |e| e.include?("does not belong to") }) + end + + test "a reporter missing its organisation details is refused" do + report = valid_report + report["ReporterInfo"].delete("ReporterOrgEmail") + + parser = Xarf::ReportParser.new(report) + + assert_not parser.valid? + assert(parser.errors.any? { |e| e.include?("ReporterOrgEmail") }) + end + + test "a natural person may report without organisation details" do + parser = Xarf::ReportParser.new( + valid_report("ReporterInfo" => { "ReporterType" => "Person" }) + .tap { |r| r["ReporterInfo"] = { "ReporterType" => "Person" } } + ) + + assert parser.valid?, parser.errors.join(", ") + end + + test "Disclosure set to false is present, not missing" do + parser = Xarf::ReportParser.new(valid_report("Disclosure" => false)) + + assert parser.valid?, parser.errors.join(", ") + assert_equal false, parser.parse[:disclosure] + end + + test "a malformed date is refused" do + parser = Xarf::ReportParser.new(valid_report("Report" => { "Date" => "yesterday" })) + + assert_not parser.valid? + end + + test "invalid JSON is reported rather than raised" do + parser = Xarf::ReportParser.new("{not json") + + assert_not parser.valid? + assert_includes parser.errors, "Invalid JSON format" + end + + test "parsing an invalid report raises" do + assert_raises(Xarf::ReportParser::InvalidReportError) do + Xarf::ReportParser.new({}).parse + end + end + + test "samples are summarised without carrying the payload" do + report = valid_report( + "Report" => { + "Samples" => [ + { "ContentType" => "text/html", "Description" => "The page", "Payload" => "x" } + ] + } + ) + + sample = Xarf::ReportParser.new(report).parse[:samples].first + + assert_equal "text/html", sample[:content_type] + assert_equal 14, sample[:payload_size] + assert_not sample.key?(:payload) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index e4a340c..dd81453 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -78,6 +78,40 @@ def create_test_service(attrs = {}) }.merge(attrs) ) end + + def create_test_abuse_contact(attrs = {}) + Report::AbuseContact.create!( + { + name: "Test Host #{SecureRandom.hex(4)}", + contact_type: :hosting, + method: :email, + email: "abuse@example.com" + }.merge(attrs) + ) + end + + def create_test_report_case(confidence: 0.95, domain_info: {}, sources: [ { "service" => "TestSource" } ]) + domain = Phish::Domain.create!(domain: "bad-#{SecureRandom.hex(4)}.com") + verdict = Verdict.create!( + classification: "phishing", + confidence_score: confidence, + sources: sources + ) + + Report::Case.create!( + reportable: domain, + verdict_snapshot: verdict, + confidence_at_creation: confidence, + domain_info: domain_info + ) + end + + def create_test_submission(report_case, contact) + report_case.submissions.create!( + abuse_contact: contact, + payload: report_case.submissions.build(abuse_contact: contact).build_payload + ) + end end end From c655caa98296c9e9fc5446024b0e1eb479ae2452 Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Mon, 7 Sep 2026 14:09:24 -0400 Subject: [PATCH 2/2] fix: point the public XARF tool at schema 3 The public tool landed on main while this branch was open, and the two disagree about the wire format. The tool asserted an "xarf_version" of "4.0.0", which appears in no published schema. This branch moves the generator to schema 3 of https://github.com/abusix/xarf, so the tool emitted schema 3 while its tests still expected the invented shape. Update the tool's tests to the schema 3 keys, and correct the "XARF v4" labels in the views, the docs page and two comments. Those labels named a version the payload does not carry. The parser test that refuses the v4 shape stays as it is. Refusing it is the point. Claude-Session: https://claude.ai/code/session_018gopCvu5KgRjeWBM4Zw6pm --- app/controllers/xarf_controller.rb | 2 +- app/services/xarf/public_report_service.rb | 2 +- app/views/admin/phish_domains/show.html.erb | 2 +- app/views/admin/phish_urls/show.html.erb | 2 +- app/views/docs/index.html.erb | 2 +- app/views/xarf/_report.html.erb | 2 +- app/views/xarf/new.html.erb | 2 +- test/integration/xarf_tool_test.rb | 18 +++++++++--------- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/controllers/xarf_controller.rb b/app/controllers/xarf_controller.rb index af120e5..c8373ae 100644 --- a/app/controllers/xarf_controller.rb +++ b/app/controllers/xarf_controller.rb @@ -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 diff --git a/app/services/xarf/public_report_service.rb b/app/services/xarf/public_report_service.rb index 04ab336..6fafd91 100644 --- a/app/services/xarf/public_report_service.rb +++ b/app/services/xarf/public_report_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Xarf - # Turns something a person pasted into a browser into a XARF v4 report. + # Turns something a person pasted into a browser into an X-ARF report. # # The point raised against XARF is that it asks an ordinary reporter to # hand-write JSON, which gatekeeps who is able to report abuse at all. This diff --git a/app/views/admin/phish_domains/show.html.erb b/app/views/admin/phish_domains/show.html.erb index b83ebc3..e8818c5 100644 --- a/app/views/admin/phish_domains/show.html.erb +++ b/app/views/admin/phish_domains/show.html.erb @@ -106,7 +106,7 @@
-

XARF v4 Report

+

X-ARF Report

Standardized abuse report format

<% if @xarf_report %> diff --git a/app/views/admin/phish_urls/show.html.erb b/app/views/admin/phish_urls/show.html.erb index fca1b98..4b53e44 100644 --- a/app/views/admin/phish_urls/show.html.erb +++ b/app/views/admin/phish_urls/show.html.erb @@ -98,7 +98,7 @@
-

XARF v4 Report

+

X-ARF Report

Standardized abuse report format

<% if @xarf_report %> diff --git a/app/views/docs/index.html.erb b/app/views/docs/index.html.erb index 07561ea..02a7b31 100644 --- a/app/views/docs/index.html.erb +++ b/app/views/docs/index.html.erb @@ -40,7 +40,7 @@ <%= link_to xarf_path, class: "bg-pd-secondary border border-slate-700/20 rounded-2xl p-6 hover:border-cyan-accent/30 transition-all group" do %>
🧾

XARF Generator

-

Paste a phishing link and get a XARF v4 report you can send to any provider.

+

Paste a phishing link and get an X-ARF report you can send to any provider.

<% end %> <%= link_to docs_page_path(page: 'trusted-sources'), class: "bg-pd-secondary border border-slate-700/20 rounded-2xl p-6 hover:border-cyan-accent/30 transition-all group" do %> diff --git a/app/views/xarf/_report.html.erb b/app/views/xarf/_report.html.erb index b5ab3d9..9f6d570 100644 --- a/app/views/xarf/_report.html.erb +++ b/app/views/xarf/_report.html.erb @@ -30,7 +30,7 @@ <%# The report itself. %>
-

XARF v4 JSON

+

X-ARF JSON