diff --git a/.document b/.document new file mode 100644 index 0000000..7f80cfc --- /dev/null +++ b/.document @@ -0,0 +1,3 @@ +- +ChangeLog.md +LICENSE.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..055f6b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +Gemfile.lock +doc/ +pkg/ +vendor/cache/*.gem +*.eml diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..660778b --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--colour --format documentation diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..77fee73 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +1.9.3 diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..694107c --- /dev/null +++ b/.yardopts @@ -0,0 +1 @@ +--markup markdown --title "TackleBox Documentation" --protected diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100644 index 0000000..a222030 --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,4 @@ +### 0.1.0 / 2014-06-03 + +* Initial release: + diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..952588e --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +source 'https://rubygems.org/' + +gemspec + +group :development do + gem 'rake' + gem 'rspec', '~> 3.0' + + gem 'kramdown' + gem 'yard', '~> 0.8' +end diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..801e02e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1 @@ +Copyright (c) 2014 Trail of Bits, Inc. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac437e3 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# tacklebox + +* [Homepage](https://github.com/trailofbits/tackle_box) +* [Documentation](http://rubydoc.info/gems/tackle_box/frames) + +## Description + +A phishing toolkit for generating and sending phishing emails. + +## Features + +* Provides a [Liquid][liquid] + Markdown email template system. +* Provides built-in [liquid] tags for customized emails: + * {TackleBox::Email::Template::Tags::Attachment attachment} + * {TackleBox::Email::Template::Tags::Date date} + * {TackleBox::Email::Template::Tags::Day day} + * {TackleBox::Email::Template::Tags::Link link} + * {TackleBox::Email::Template::Tags::RandomDate random_date} + * {TackleBox::Email::Template::Tags::RandomDay random_day} + * {TackleBox::Email::Template::Tags::RandomGreeting random_greeting} + * {TackleBox::Email::Template::Tags::RandomWeekDay random_week_day} + * {TackleBox::Email::Template::Tags::WeekDay week_day} + * {TackleBox::Email::Template::Tags::Year year} +* Renders dynamic emails based on the recipient. +* Supports targeting specific sub-groups of people within an organization. +* Supports embedding links and/or attachments into emails. +* Supports custom 1x1 tracking pixel URLs. + +## API + +* {TackleBox::Attachment} +* {TackleBox::Link} +* {TackleBox::Person} +* {TackleBox::Organization} +* {TackleBox::Campaign} + * {TackleBox::Campaign#initialize} + * {TackleBox::Campaign#each_email} + +## Examples + + require 'tackle_box' + + email_template = TackleBox::Email::Template.open('nude_pics.eml') + + org = TackleBox::Organization.new( + 'Trail of Bits, Inc.', + + domain: 'trailofbits.com', + website: 'http://www.trailofbits.com/', + industry: 'Technology' + ) + org.person('dan@trailofbits.com', name: 'Dan Guido', + title: 'CEO', + department: 'Management', + location: 'New York, NY') + + campaign = TackleBox::Campaign.new( + email_template, org, + + link: 'http://www.imdurr.com/gallery/x5ae18.png', + tracking_pixel: 'http://www.imdurr.com/pixel.gif', + account: TackleBox::Person.new( + 'nick.d@yelpfortoilets.com', + name: 'Nick Depetrillo', + title: 'CTO' + ) + ) + + sender = TackleBox::Email::Sender.new( + 'trailofberts.com' => { + address: 'mail.yelpfortoilets.com', + port: 587, + user_name: 'nick.d', + password: 'test1234', + authentication: :plain, + enable_starttls_auto: true + } + ) + + campaign.each_email do |email| + puts ">>> Sending email to #{email.to}" + puts email + + sender.send(email) + end + +### Example Email Template + + Date: Tue, 01 Jul 2014 14:21:08 -0700 + Message-ID: <53b32644bd0dd_b221112ff0516b4@tank.lab.mail> + Subject: Nude pics! + Mime-Version: 1.0 + Content-Type: text/html; + charset=ISO-8895-1 + Content-Transfer-Encoding: 7bit + + + + + test email + + + Yo {{ to.name }}, checkout these nudes + of Brian Krebs! + + + +## Requirements + +* [Ruby] >= 1.9.3 +* [activesupport] ~> 3.2 +* [shorturl] ~> 1.0 +* [liquid] ~> 2.4 +* [nokogiri] ~> 1.6 +* [chars] ~> 0.2 +* [mail] ~> 2.5 + +## Install + + $ bundle install + +## Testing + + $ rake spec + +## Documentation + + $ rake yard + $ open ./doc/index.html + +## Copyright + +Copyright (c) 2014 Trail of Bits, Inc. + +[Ruby]: http://www.ruby-lang.org/ +[activesupport]: https://github.com/rails/rails/tree/master/activesupport#readme +[shorturl]: https://github.com/robbyrussell/shorturl#readme +[liquid]: http://liquidmarkup.org/ +[nokogiri]: http://nokogiri.org/ +[chars]: https://github.com/postmodern/chars#readme +[mail]: https://github.com/mikel/mail diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..3dc3a91 --- /dev/null +++ b/Rakefile @@ -0,0 +1,32 @@ +# encoding: utf-8 + +require 'rubygems' + +begin + require 'bundler' +rescue LoadError => e + warn e.message + warn "Run `gem install bundler` to install Bundler." + exit -1 +end + +begin + Bundler.setup(:development) +rescue Bundler::BundlerError => e + warn e.message + warn "Run `bundle install` to install missing gems." + exit e.status_code +end + +require 'rake' +require 'bundler/gem_tasks' + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new + +task :test => :spec +task :default => :spec + +require 'yard' +YARD::Rake::YardocTask.new +task :doc => :yard diff --git a/bin/edit b/bin/edit new file mode 100755 index 0000000..de199e6 --- /dev/null +++ b/bin/edit @@ -0,0 +1,74 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'tackle_box/version' +require 'tackle_box/email/editor' + +require 'optparse' + +replacements = [] +domains = [] + +from = nil +from_name = nil +dry_run = false + +options = OptionParser.new("usage: #{$0} [OPTIONS] EMAIL") do |opts| + opts.on('-R','--replace STRING:SUB','Replaces STRING with SUB') do |arg| + replacements << arg.split(':',2) + end + + opts.on('-f','--from [ADDR]','Replaces the From address with ADDR') do |addr| + from = addr || '{{ from.address }}' + end + + opts.on('-F','--from-name [NAME]','Replaces the From name') do |name| + from_name = name || '{{ from.name }}' + end + + opts.on('-D','--domain OLD:NEW','Replaces the OLD domain with NEW') do |arg| + domains << arg.split(':',2) + end + + opts.on('-d','--dry-run','Do not save the edits') do + dry_run = true + end + + opts.on('-V','--version','Prints the tacklebox version') do + puts TackleBox::VERSION + exit + end + + opts.on('-h','--help','Prints this message') do + puts opts + exit + end +end + +options.parse!(ARGV) + +unless (path = ARGV.first) + abort "#{$0}: EMAIL argument not specified" +end + +editor = TackleBox::Email::Editor.new(path) + +# always dequote the email +editor.dequote + +editor.replace_to +editor.replace_to_name +editor.replace_from(from) if from +editor.replace_from_name(from_name) if from_name + +domains.each do |(old,new)| + editor.replace_domain(old,new) +end + +replacements.each do |(str,sub)| + editor.replace(str,sub) +end + +unless dry_run then editor.save +else puts editor +end diff --git a/lib/tackle_box.rb b/lib/tackle_box.rb new file mode 100644 index 0000000..6e9da3b --- /dev/null +++ b/lib/tackle_box.rb @@ -0,0 +1,6 @@ +require 'tackle_box/person' +require 'tackle_box/organization' +require 'tackle_box/attachment' +require 'tackle_box/link' +require 'tackle_box/campaign' +require 'tackle_box/version' diff --git a/lib/tackle_box/attachment.rb b/lib/tackle_box/attachment.rb new file mode 100644 index 0000000..1c772c1 --- /dev/null +++ b/lib/tackle_box/attachment.rb @@ -0,0 +1,96 @@ +module TackleBox + class Attachment + + # The path to the file to attach + attr_reader :path + + # The desired name of the attachment + attr_reader :name + + # The contents for the attachment + attr_reader :content + + # The encoding of the attachment + attr_reader :encoding + + # The password for the attachment + attr_reader :password + + # + # Initializes the attachment. + # + # @param [Hash] options + # Additional options. + # + # @option options [String] :name + # The desired name for the attachment. + # Defaults to the basename of the path. + # + # @option options [String] :content + # The optional contents for the attachment. + # Defaults to the contents of the file. + # + # @option options [String] :encoding + # The encoding of the attachment (ex: `zip`, `7z`, `rar`). + # Defaults to the extension of the attachment name. + # + # @option options [String] :password + # The password for the attachment. + # + # @raise [ArgumentError] + # Either `:path` or `:content` must be specified, or `:name` + # + # @example Use an existing file: + # Attachment.new(path: 'path/to/nudes.exe') + # + # @example Use an existing file but with a different name: + # Attachment.new(path: 'path/to/nudes.exe', name: 'nudes.jpg') + # + # @example Specify a custom name and contents: + # Attachment.new(name: 'nudes.jpg', content: data) + # + def initialize(options={}) + unless (options[:path] || options[:content]) + raise(ArgumentError,"must specify :path or :content") + end + + @path = options[:path] + @name = options.fetch(:name) do + unless @path + raise(ArgumentError,":path or :name are required") + end + + File.basename(@path) + end + + @content = options.fetch(:content) do + File.binread(@path) + end + + @encoding = options.fetch(:encoding) do + File.extname(@name)[1..-1] + end + + @password = options[:password] + end + + # + # Converts the attachment into a liquid hash. + # + # @return [Hash{String => String,nil}] + # The liquid hash containing: + # + # * `name` + # * `encoding` + # * `password` + # + def to_liquid + { + 'name' => @name, + 'encoding' => @encoding, + 'password' => @password + } + end + + end +end diff --git a/lib/tackle_box/campaign.rb b/lib/tackle_box/campaign.rb new file mode 100644 index 0000000..44f4748 --- /dev/null +++ b/lib/tackle_box/campaign.rb @@ -0,0 +1,240 @@ +require 'tackle_box/organization' +require 'tackle_box/email/template' +require 'tackle_box/link' +require 'tackle_box/attachment' + +require 'chars' +require 'mail' + +module TackleBox + class Campaign + + # Headers for common email mailers + HEADERS = { + 'outlook' => {'X-Mailer' => 'Microsoft Office Outlook, Build 12.0.4210'}, + 'apple_mail' => {'X-Mailer' => 'Apple Mail (2.1498)'}, + 'thunderbird' => {'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:15.0) Gecko/20120911 Thunderbird/15.0.1'} + } + + # Default domain to use for additional resources + DEFAULT_DOMAIN = 'apt.trailofbits.com' + + # The organization that's being targetted. + # + # @return [Organization] + attr_reader :organization + + # The email template to use. + # + # @return [Email::Template] + attr_reader :email_template + + # The link to send. + # + # @return [Link] + attr_reader :link + + # The attachment to send. + # + # @return [Attachment] + attr_reader :attachment + + # The accounts to send emails from. + # + # @return [Array] + attr_reader :accounts + + # Intended recipients of the campaign. + # + # @return [Array] + attr_reader :recipients + + # Mapping of identification tokens back to recipients. + # + # @return [Hash{String => Person}] + attr_reader :tokens + + # + # Initializes the campaign. + # + # @param [Email::Template] email_template + # The email template to render. + # + # @param [Hash] options + # Additional options. + # + # @option options [String, URI::HTTP] :link + # The link to use. + # + # @option options [Hash] :attachment + # The attachment to embed. See {Attachment#initialize} for options. + # + # @option options [Person] :account + # The account that emails will be sent from. + # + # @option options [Array] :accounts + # The accounts to send emails from. + # + # @option options [Array] :recipients + # The intended recipients of the campaign. + # + # @option options [String] :department + # Targets all people in the organization's department. + # + # @option options [String] :location + # Targets all people in the given location. + # + # @raise [ArgumentError] + # The email template requires a `:accounts`, `:link` or `:attachment` + # option. + # + def initialize(email_template,organization,options={}) + @organization = organization + @email_template = email_template + + @mailer = options[:mailer] + + if options[:link] + @link = Link.new(options[:link]) + elsif @email_template.requires_link? + raise(ArgumentError,"email template requires a link") + end + + if options[:tracking_pixel] + @tracking_pixel = options[:tracking_pixel] + end + + if options[:attachment] + @attachment = Attachment.new(options[:attachment]) + elsif @email_template.requires_attachment? + raise(ArgumentError,"email template requires an attachment") + end + + @recipients = if options[:recipients] + options[:recipients] + elsif options[:department] + organization.people_in_department(options[:department]) + elsif options[:location] + organization.people_from_location(options[:location]) + else + organization.people + end + + @accounts = [] + + if options[:account] + @accounts << options[:account] + elsif options[:accounts] + @accounts += options[:accounts] + elsif @email_template.requires_from? + raise(ArgumentError,"email template requires at least one account") + end + + @tokens = {} + + @recipients.each do |recipient| + @tokens[new_token] = recipient + end + end + + # + # Lookups the token for the given recipient. + # + # @param [Recipient] recipient + # The recipient to search for. + # + # @return [String, nil] + # The token for the recipient. + # + def token_for(recipient) + token, _ = @tokens.find do |token,recipient| + recipient == recipient + end + + return token + end + + # + # Generates emails for each recipient. + # + # @yield [email] + # The given block will be passed each new message. + # + # @yieldparam [Mail::Message] email + # A newly generated email message. + # + def each_email + return enum_for(__method__) unless block_given? + + @recipients.each do |recipient| + yield email(recipient,token_for(recipient)) + end + end + + # + # Generates an email. + # + # @param [Person] to + # The person sending the email. + # + # @param [String] token + # Unique token to identify the recipient with. + # + # @param [Person] from + # The account to send the email from. + # + # @return [Mail::Message] + # The newly generated email. + # + # @raise [RuntimeError] + # No sending account was given. + # + def email(to,token,from=@accounts.sample) + unless from + raise(ArgumentError,"no sending account given") + end + + variables = to_liquid.merge( + 'to' => to.to_liquid, + 'from' => from.to_liquid, + 'token' => token + ) + + email = @email_template.render(variables) + + if @attachment + email.add_file( + filename: @attachment.name, + content: @attachment.content + ) + end + + return email + end + + # + # Converts the campaign to a liquid hash. + # + # @return [Hash{String => Hash}] + # The liquid hash containing: + # + # * `organization` + # * `link` + # * `attachment` + # + def to_liquid + { + 'organization' => @organization.to_liquid, + 'link' => (@link.to_liquid if @link), + 'attachment' => (@attachment.to_liquid if @attachment) + } + end + + private + + def new_token + Chars::ALPHA_NUMERIC.random_string(12) + end + + end +end diff --git a/lib/tackle_box/email.rb b/lib/tackle_box/email.rb new file mode 100644 index 0000000..2f73968 --- /dev/null +++ b/lib/tackle_box/email.rb @@ -0,0 +1 @@ +require 'tackle_box/email/cloner' diff --git a/lib/tackle_box/email/cloner.rb b/lib/tackle_box/email/cloner.rb new file mode 100644 index 0000000..2839cd1 --- /dev/null +++ b/lib/tackle_box/email/cloner.rb @@ -0,0 +1,88 @@ +require 'mail' +require 'nokogiri' +require 'uri' + +module TackleBox + module Email + module Cloner + + # The link tag to replace all links with + LINK_TAG = '{% link %}' + + # + # Clones the email. + # + # @param [String] email + # The raw email message. + # + # @param [Hash] attributes + # Additional attributes for the new email template. + # + # @return [EmailTemplate] + # The new email template. + # + def self.clone(email,attributes={}) + email = Mail.read_from_string(email) + + html = email.parts.find { |part| part.mime_type == 'text/html' } + text = email.parts.find { |part| part.mime_type == 'text/plain' } + + body = if html then rewrite_html(html.decoded) + elsif text then rewrite_text(text.decoded) + else rewrite_text(email.body.decoded) + end + + return EmailTemplate.new(attributes.merge( + subject: email.subject, + body: body + )) + end + + # + # Rewrites the HTML replacing every `a` link with the `{% link %}` tag. + # + # @param [String] html + # The raw HTML to rewrite. + # + # @return [String] + # The rewritten HTML. + # + def self.rewrite_html(html) + doc = Nokogiri::HTML(html) + body = doc.at('//body') + + place_holder = 'LINK_GOES_HERE' + + body.search('//a/@href').each do |href| + href.value = place_holder + end + + html = body.inner_html + html.strip! + html.gsub!(place_holder,LINK_TAG) + + return html + end + + # + # Rewrites the text replacing every URL with the `{% link %}` tag. + # + # @param [String] text + # The raw text. + # + # @return [String] + # The rewritten text. + # + def self.rewrite_text(text) + links = URI.extract(text,['http', 'https']).uniq + + links.each do |link| + text.gsub!(link,LINK_TAG) + end + + return text + end + + end + end +end diff --git a/lib/tackle_box/email/editor.rb b/lib/tackle_box/email/editor.rb new file mode 100644 index 0000000..2598469 --- /dev/null +++ b/lib/tackle_box/email/editor.rb @@ -0,0 +1,211 @@ +require 'mail' + +module TackleBox + module Email + class Editor + + # Path to the email + # + # @return [String] + attr_reader :path + + # The parsed email + # + # @return [Mail::Message] + attr_reader :email + + # + # Initializes the email editor. + # + # @param [String] path + # The path to the email. + # + def initialize(path) + @path = path + @email = Mail.read(@path) + + @to = @email.header[:to].address_list.addresses.first + @from = @email.header[:from].address_list.addresses.first + end + + # + # Yields the email for modification. + # + # @yield [email] + # The given block will be passed the parsed email. + # + # @yieldparam [Mail::Message] email + # The parsed email. + # + def edit + yield @email + + # force a re-parse of the modified email + @email = Mail.read_from_string(@email.to_s) + end + + # + # Enumerates over each body part within the email. + # + # @yield [part] + # The given block will be passed each body party. + # + # @yieldparam [Mail::Message, Mail::Part] part + # A body part from the email. If the email only has a single body + # part, the entire `Mail::Mesage` will be yielded. + # + def edit_body_parts(&block) + return enum_for(__method__) unless block_given? + + edit do |email| + unless email.parts.empty? then email.parts.each(&block) + else yield email + end + end + end + + # + # De-`quote-printable`s the email. + # + def dequote + edit_body_parts do |part| + if part.content_transfer_encoding == 'quoted-printable' + part.content_transfer_encoding = '7bit' + part.body = part.decoded + end + end + end + + # + # Performs global find and replace on the email. + # + # @param [Regexp, String] pattern + # The pattern to replace. + # + # @param [String, nil] substitute + # The substitute data. + # + # @yield [match] + # If a block is given, then it will be used to transform the matched + # data. + # + # @yieldparam [MatchData] match + # The match data. + # + def replace(pattern,substitute=nil,&block) + gsub = lambda { |str| + str.gsub(pattern,substitute,&block) + } + + edit do |email| + email.header = gsub.call(email.header.raw_source) + end + + edit_body_parts do |part| + part.body = gsub.call(part.body.decoded) + end + end + + # + # Replaces the old name with a new name. + # + # @param [String] old_name + # The old name to replace. + # + # @param [String] new_name + # The new name to replace with. + # + def replace_name(old_name,new_name) + old_first_name, old_last_name = old_name.split(' ',2) + new_first_name, new_last_name = new_name.split(' ',2) + + replace(old_name,new_name) + replace(old_last_name,new_last_name) + replace(old_first_name,new_first_name) + end + + # + # Replaces all `From:` addresses globally. + # + # @param [String] new_from + # The new `From:` address. + # + def replace_from(new_from='{{ from.address }}') + replace(@from.address,new_from) + end + + # + # Replaces the name in the `From:` header globally. + # + # @param [String] new_name + # The new name to use. + # + def replace_from_name(new_name=nil) + if @from.display_name + if new_name + replace_name(@from.display_name,new_name) + else + replace(@from.display_name,'{{ from.name }}') + end + end + end + + # + # Replaces the `To:` address globally. + # + # @param [String] new_addr + # The new `To:` address to use. + # + def replace_to(new_addr='{{ to.address }}') + replace(@to.address,new_addr) + end + + # + # Replaces the name in the `To:` address globally. + # + # @param [String] new_name + # The new name to use. + # + def replace_to_name(new_name=nil) + if @to.display_name + if new_name + replace_name(@to.display_name,new_name) + else + replace(@to.display_name,'{{ to.name }}') + end + end + end + + # + # Replaces the domain globally. + # + # @param [String] domain + # The domain to replace. + # + # @param [String] new_domain + # The new domain to use. + # + def replace_domain(domain,new_domain='{{ link.host }}') + replace(domain,new_domain) + end + + # + # Dumps out the edited email. + # + # @return [String] + # The raw email. + # + def to_s + @email.to_s + end + + # + # Saves the edited email. + # + def save + File.write(@path,@email) + end + + end + end +end diff --git a/lib/tackle_box/email/sender.rb b/lib/tackle_box/email/sender.rb new file mode 100644 index 0000000..26ccb7f --- /dev/null +++ b/lib/tackle_box/email/sender.rb @@ -0,0 +1,45 @@ +require 'mail' + +module TackleBox + module Email + class Sender + + # The SMTP credentials grouped by domain. + # + # @return [Hash{String => Hash}] + attr_reader :domains + + # + # Initializes the sender. + # + # @param [Hash{String => Hash}] domains + # SMTP credentials grouped by domain. + # + def initialize(domains) + @domains = domains + end + + # + # Sends the email via the appropriate SMTP server. + # + # @param [Mail::Message] email + # The email to send. + # + # @raise [RuntimeError] + # Could not find SMTP credentials for the from address in the email. + # + def send(email) + from = email.from_addrs.first + domain = from.split('@',2).last + + credentials = @domains.fetch(domain) do + raise("no credentials for SMTP server: #{domain}") + end + + email.delivery_method :smtp, credentials + email.deliver + end + + end + end +end diff --git a/lib/tackle_box/email/template.rb b/lib/tackle_box/email/template.rb new file mode 100644 index 0000000..fea8490 --- /dev/null +++ b/lib/tackle_box/email/template.rb @@ -0,0 +1,131 @@ +require 'tackle_box/email/template/extensions' +require 'tackle_box/email/template/filters' +require 'tackle_box/email/template/tags' + +require 'mail' +require 'liquid' +require 'nokogiri' + +module TackleBox + module Email + class Template + + # The email to be rendered. + # + # @return [String] + attr_reader :email + + # + # Initializes the email template. + # + # @param [String] email + # The raw email to render. + # + def initialize(email) + @email = email + end + + # + # Loads an email template from a file. + # + # @param [String] path + # Path to the `.eml` file. + # + # @return [Template] + # The loaded template. + # + def self.open(path) + new(File.read(path)) + end + + # + # Determines if the email template requires the `to` variable. + # + # @return [Boolean] + # Specifies whether the email template uses the `to` variable. + # + def requires_to? + @email.include?('{{ to.') + end + + # + # Determines if the email template requires the `from` variable. + # + # @return [Boolean] + # Specifies whether the email template uses the `from` variable. + # + def requires_from? + @email.include?('{{ from.') + end + + # + # Determines if the email template requires the `link` variable. + # + # @return [Boolean] + # Specifies whether the email template uses the `link` variable. + # + def requires_link? + @email.include?('{% link %}') || + @email.include?('{{ link.') + end + + # + # Determines if the email template requires the `attachment` variable. + # + # @return [Boolean] + # Specifies whether the email template uses the `attachment` variable. + # + def requires_attachment? + @email.include?('{% attachment %}') || + @email.include?('{{ attachment.') + end + + # + # Renders the given liquid text. + # + # @param [String] text + # The text containing liquid tags. + # + # @param [Hash{String => String,Array,Hash}] variables + # Liquid template variables. + # + # @return [String] + # The rendered text. + # + def render_liquid(text,variables) + liquid = Liquid::Template.parse(text) + liquid.render(variables) + end + + # + # Renders the email. + # + # @param [Hash{String => String,Array,Hash}] variables + # Liquid template variables. + # + # @return [String] + # The rendered email. + # + def render(variables) + new_email = Mail.read_from_string(@email) + new_email.header = render_liquid(new_email.header.raw_source,variables) + + render_and_replace = lambda { |part| + # only render text/plain and text/html parts + if part.content_type =~ %r{^text/(plain|html)} + part.body = render_liquid(part.body.decoded,variables) + end + } + + unless new_email.body.parts.empty? + new_email.body.parts.each(&render_and_replace) + else + render_and_replace.call(new_email) + end + + return new_email + end + + end + end +end diff --git a/lib/tackle_box/email/template/extensions.rb b/lib/tackle_box/email/template/extensions.rb new file mode 100644 index 0000000..a5e1163 --- /dev/null +++ b/lib/tackle_box/email/template/extensions.rb @@ -0,0 +1 @@ +require 'tackle_box/email/template/extensions/uri' diff --git a/lib/tackle_box/email/template/extensions/uri.rb b/lib/tackle_box/email/template/extensions/uri.rb new file mode 100644 index 0000000..7953fc4 --- /dev/null +++ b/lib/tackle_box/email/template/extensions/uri.rb @@ -0,0 +1 @@ +require 'tackle_box/email/template/extensions/uri/http' diff --git a/lib/tackle_box/email/template/extensions/uri/http.rb b/lib/tackle_box/email/template/extensions/uri/http.rb new file mode 100644 index 0000000..bc3fe13 --- /dev/null +++ b/lib/tackle_box/email/template/extensions/uri/http.rb @@ -0,0 +1,25 @@ +require 'uri/http' + +module URI + class HTTP < Generic + + # + # Breaks the HTTP URI down into a liquid Hash of it's components. + # + # @return [Hash{String => String}] + # The liquid Hash containing `url`, `scheme`, `host`, `port`, `path` + # and `query`. + # + def to_liquid + { + 'url' => self.to_s, + 'scheme' => self.scheme, + 'host' => self.host, + 'port' => self.port, + 'path' => self.path, + 'query' => self.query + } + end + + end +end diff --git a/lib/tackle_box/email/template/filters.rb b/lib/tackle_box/email/template/filters.rb new file mode 100644 index 0000000..37d99f4 --- /dev/null +++ b/lib/tackle_box/email/template/filters.rb @@ -0,0 +1,78 @@ +require 'liquid' + +require 'active_support/inflector' + +module TackleBox + module Email + class Template + module Filters + # + # Converts the input to all lower-case. + # + # @param [String] input + # The input. + # + # @return [String] + # The lower-cased output. + # + def lowercase(input) + input.downcase + end + + # + # Converts the input to all upper-case. + # + # @param [String] input + # The input. + # + # @return [String] + # The upper-cased output. + # + def uppercase(input) + input.upcase + end + + # + # Capitalizes the first character of the input. + # + # @param [String] input + # The input. + # + # @return [String] + # The capitalized output. + # + def capitalize(input) + input.capitalize + end + + # + # Pluralizes the input. + # + # @param [String] input + # The input. + # + # @return [String] + # The pluralized output. + # + def pluralize(input) + ActiveSupport::Inflector.pluralize(input) + end + + # + # Singularizes the input. + # + # @param [String] input + # The input. + # + # @return [String] + # The singularized output. + # + def singularize(input) + ActiveSupport::Inflector.singularize(input) + end + end + end + end +end + +Liquid::Template.register_filter(TackleBox::Email::Template::Filters) diff --git a/lib/tackle_box/email/template/tags.rb b/lib/tackle_box/email/template/tags.rb new file mode 100644 index 0000000..1dda5e9 --- /dev/null +++ b/lib/tackle_box/email/template/tags.rb @@ -0,0 +1,12 @@ +require 'tackle_box/email/template/tags/day' +require 'tackle_box/email/template/tags/week_day' +require 'tackle_box/email/template/tags/date' +require 'tackle_box/email/template/tags/year' + +require 'tackle_box/email/template/tags/random_day' +require 'tackle_box/email/template/tags/random_week_day' +require 'tackle_box/email/template/tags/random_date' +require 'tackle_box/email/template/tags/random_greeting' + +require 'tackle_box/email/template/tags/attachment' +require 'tackle_box/email/template/tags/link' diff --git a/lib/tackle_box/email/template/tags/attachment.rb b/lib/tackle_box/email/template/tags/attachment.rb new file mode 100644 index 0000000..41c47f6 --- /dev/null +++ b/lib/tackle_box/email/template/tags/attachment.rb @@ -0,0 +1,34 @@ +require 'liquid' + +module TackleBox + module Email + class Template + module Tags + class Attachment < Liquid::Tag + + TEXT = [ + 'password:', + 'pass:', + 'passwd:', + 'the password is' + ] + + def render(context) + if (attachment = context['attachment']) + text = attachment['name'].dup + + if attachment['password'] + text << " (#{TEXT.sample} #{attachment['password']})" + end + + return text + end + end + + end + end + end + end +end + +Liquid::Template.register_tag('attachment', TackleBox::Email::Template::Tags::Attachment) diff --git a/lib/tackle_box/email/template/tags/date.rb b/lib/tackle_box/email/template/tags/date.rb new file mode 100644 index 0000000..de87d19 --- /dev/null +++ b/lib/tackle_box/email/template/tags/date.rb @@ -0,0 +1,20 @@ +require 'liquid' +require 'date' + +module TackleBox + module Email + class Template + module Tags + class Date < Liquid::Tag + + def render(context) + ::Date.today.strftime("%b %e") + end + + end + end + end + end +end + +Liquid::Template.register_tag('date', TackleBox::Email::Template::Tags::Date) diff --git a/lib/tackle_box/email/template/tags/day.rb b/lib/tackle_box/email/template/tags/day.rb new file mode 100644 index 0000000..7bffb00 --- /dev/null +++ b/lib/tackle_box/email/template/tags/day.rb @@ -0,0 +1,20 @@ +require 'liquid' +require 'date' + +module TackleBox + module Email + class Template + module Tags + class Day < Liquid::Tag + + def render(context) + ::Date.today.strftime("%e").strip + end + + end + end + end + end +end + +Liquid::Template.register_tag('day', TackleBox::Email::Template::Tags::Day) diff --git a/lib/tackle_box/email/template/tags/link.rb b/lib/tackle_box/email/template/tags/link.rb new file mode 100644 index 0000000..ff52fbc --- /dev/null +++ b/lib/tackle_box/email/template/tags/link.rb @@ -0,0 +1,45 @@ +require 'liquid' +require 'uri' +require 'shorturl' + +module TackleBox + module Email + class Template + module Tags + class Link < Liquid::Tag + + TOKEN_PARAM = 'token' + + def render(context) + if (link = context['link']) + + url = URI::HTTP.build( + host: link['host'], + port: link['port'], + path: link['path'], + query: link['query'] + ) + + param = "#{TOKEN_PARAM}=#{context['token']}" + + if (url.query.nil? || url.query.empty?) + url.query = param + else + url.query << "&#{param}" + end + + if context['link_shortener'] + url = ShortURL.shorten(url.to_s,context['link_shortener'].to_sym) + end + + return url + end + end + + end + end + end + end +end + +Liquid::Template.register_tag('link', TackleBox::Email::Template::Tags::Link) diff --git a/lib/tackle_box/email/template/tags/random_date.rb b/lib/tackle_box/email/template/tags/random_date.rb new file mode 100644 index 0000000..7aa6d47 --- /dev/null +++ b/lib/tackle_box/email/template/tags/random_date.rb @@ -0,0 +1,21 @@ +require 'liquid' + +module TackleBox + module Email + class Template + module Tags + class RandomDate < Liquid::Tag + + def render(context) + today = Date.today + + return today.strftime("%b #{rand(today.day - 1) + 1}") + end + + end + end + end + end +end + +Liquid::Template.register_tag('random_date', TackleBox::Email::Template::Tags::RandomDate) diff --git a/lib/tackle_box/email/template/tags/random_day.rb b/lib/tackle_box/email/template/tags/random_day.rb new file mode 100644 index 0000000..acecb01 --- /dev/null +++ b/lib/tackle_box/email/template/tags/random_day.rb @@ -0,0 +1,19 @@ +require 'liquid' + +module TackleBox + module Email + class Template + module Tags + class RandomDay < Liquid::Tag + + def render(context) + rand(Date.today.day - 1) + 1 + end + + end + end + end + end +end + +Liquid::Template.register_tag('random_day', TackleBox::Email::Template::Tags::RandomDay) diff --git a/lib/tackle_box/email/template/tags/random_greeting.rb b/lib/tackle_box/email/template/tags/random_greeting.rb new file mode 100644 index 0000000..fe6fd39 --- /dev/null +++ b/lib/tackle_box/email/template/tags/random_greeting.rb @@ -0,0 +1,27 @@ +require 'liquid' + +module TackleBox + module Email + class Template + module Tags + class RandomGreeting < Liquid::Tag + + GREETINGS = [ + 'Good day', + 'How are you doing', + 'I hope everything is well with you', + 'Dear', + 'Hello' + ] + + def render(context) + GREETINGS.sample + end + + end + end + end + end +end + +Liquid::Template.register_tag('random_greeting', TackleBox::Email::Template::Tags::RandomGreeting) diff --git a/lib/tackle_box/email/template/tags/random_week_day.rb b/lib/tackle_box/email/template/tags/random_week_day.rb new file mode 100644 index 0000000..ba998f0 --- /dev/null +++ b/lib/tackle_box/email/template/tags/random_week_day.rb @@ -0,0 +1,21 @@ +require 'liquid' + +module TackleBox + module Email + class Template + module Tags + class RandomWeekDay < Liquid::Tag + + DAYS = %w[Monday Tuesday Wednesday Thursday Friday] + + def render(context) + DAYS.sample + end + + end + end + end + end +end + +Liquid::Template.register_tag('random_week_day', TackleBox::Email::Template::Tags::RandomWeekDay) diff --git a/lib/tackle_box/email/template/tags/week_day.rb b/lib/tackle_box/email/template/tags/week_day.rb new file mode 100644 index 0000000..fd4cef2 --- /dev/null +++ b/lib/tackle_box/email/template/tags/week_day.rb @@ -0,0 +1,20 @@ +require 'liquid' +require 'date' + +module TackleBox + module Email + class Template + module Tags + class WeekDay < Liquid::Tag + + def render(context) + ::Date.today.strftime("%A") + end + + end + end + end + end +end + +Liquid::Template.register_tag('week_day', TackleBox::Email::Template::Tags::WeekDay) diff --git a/lib/tackle_box/email/template/tags/year.rb b/lib/tackle_box/email/template/tags/year.rb new file mode 100644 index 0000000..458ca00 --- /dev/null +++ b/lib/tackle_box/email/template/tags/year.rb @@ -0,0 +1,20 @@ +require 'liquid' +require 'date' + +module TackleBox + module Email + class Template + module Tags + class Year < Liquid::Tag + + def render(context) + ::Date.today.year.to_s + end + + end + end + end + end +end + +Liquid::Template.register_tag('year', TackleBox::Email::Template::Tags::Year) diff --git a/lib/tackle_box/link.rb b/lib/tackle_box/link.rb new file mode 100644 index 0000000..eb7533d --- /dev/null +++ b/lib/tackle_box/link.rb @@ -0,0 +1,59 @@ +require 'uri' + +module TackleBox + class Link + + # + # Initializes the link. + # + # @param [String, URI::HTTP] link + # The URI of the link. + # + def initialize(link) + @url = URI(link) + end + + # + # The domain that is serving the link. + # + # @return [String] + # + def domain + @url.host + end + + # + # Converts the link into a liquid hash. + # + # @return [Hash{String => Integer,String}] + # The liquid hash containing: + # + # * `url` + # * `scheme` + # * `host` + # * `port` + # * `path` + # * `query` + # + def to_liquid + { + 'url' => @url.to_s, + 'scheme' => @url.scheme, + 'host' => @url.host, + 'port' => @url.port, + 'path' => @url.path, + 'query' => @url.query + } + end + + # + # Converts the link to a String. + # + # @return [String] + # + def to_s + @url.to_s + end + + end +end diff --git a/lib/tackle_box/organization.rb b/lib/tackle_box/organization.rb new file mode 100644 index 0000000..604d0e4 --- /dev/null +++ b/lib/tackle_box/organization.rb @@ -0,0 +1,124 @@ +require 'tackle_box/person' + +module TackleBox + class Organization + + # The name of the organization. + # + # @return [String] + attr_reader :name + + # The primary domain of the organization. + # + # @return [String] + attr_reader :domain + + # The website of the organization. + # + # @return [String] + attr_reader :website + + # The industry sector of the organization. + # + # @return [String] + attr_reader :industry + + # The people belonging to the organization. + # + # @return [Array] + attr_reader :people + + # + # Initializes the organization. + # + # @param [String] name + # + # @option options [String] :domain + # + # @option options [String] :website + # + # @option options [String] :industry + # + def initialize(name,options={}) + @name = name + + @domain = options[:domain] + @website = options.fetch(:website) do + "http://#{@domain}" if @domain + end + @industry = options[:industry] + + @people = [] + end + + # + # Adds a person to the organization. + # + # @param [String] address + # Email address for the person. + # + # @param [Hash] options + # Additional options, see {Person#initialize}. + # + def person(address,options={}) + @people << Person.new(address,options) + end + + # + # Searches for people in the given department. + # + # @param [String] dept + # The department to search against. + # + # @return [Array] + # The people belonging to that department. + # + def people_in_department(dept) + @people.find { |person| person.department == dept } + end + + # + # Searches for people from a given location. + # + # @param [String] loc + # The location to search against. + # + # @return [Array] + # The people in that location. + # + def people_from_location(loc) + @people.find { |person| person.location == loc } + end + + # + # Converts the organization to a liquid Hash. + # + # @return [Hash{String => String,nil}] + # The liquid hash containing: + # + # * `name` + # * `domain` + # * `website` + # * `industry` + # + def to_liquid + { + 'name' => @name, + 'domain' => @domain, + 'website' => @website, + 'industry' => @industry + } + end + + # + # Converts the organization to a String. + # + # @return [String] + # The name of the organization. + # + def to_s + @name + end + + end +end diff --git a/lib/tackle_box/person.rb b/lib/tackle_box/person.rb new file mode 100644 index 0000000..c7ff410 --- /dev/null +++ b/lib/tackle_box/person.rb @@ -0,0 +1,90 @@ +module TackleBox + class Person + + # The email address for the person + # + # @return [String] + attr_reader :address + + # The person's name + # + # @return [String, nil] + attr_reader :name + + # The person's job title + # + # @return [String, nil] + attr_reader :title + + # The person's department + # + # @return [String, nil] + attr_reader :department + + # The person's physical location. + # + # @return [String, nil] + attr_reader :location + + # + # Initializes the person. + # + # @param [String] address + # The email address of the person. + # + # @param [Hash] options + # Additional options. + # + # @option options [String] :name + # The name of the person. + # + # @option options [String] :title + # The job title of the person. + # + # @option options [String] :department + # The department the person works in. + # + def initialize(address,options={}) + @address = address + + @name = options[:name] + @title = options[:title] + @department = options[:department] + @location = options[:location] + end + + # + # Converts the person to a liquid Hash. + # + # @return [Hash{String => String,nil}] + # The liquid hash containing: + # + # * `name` + # * `address` + # * `title` + # * `department` + # + def to_liquid + { + 'name' => @name, + 'address' => @address, + 'title' => @title, + 'department' => @department, + 'location' => @location + } + end + + # + # Converts the person to a String. + # + # @return [String] + # The person's name and address. + # + def to_s + if @name then "#{@name} <#{@address}>" + else @address.to_s + end + end + + end +end diff --git a/lib/tackle_box/version.rb b/lib/tackle_box/version.rb new file mode 100644 index 0000000..9c1bcef --- /dev/null +++ b/lib/tackle_box/version.rb @@ -0,0 +1,4 @@ +module TackleBox + # tacklebox version + VERSION = "0.1.0" +end diff --git a/spec/attachment_spec.rb b/spec/attachment_spec.rb new file mode 100644 index 0000000..3cd4633 --- /dev/null +++ b/spec/attachment_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' +require 'tackle_box/attachment' + +describe TackleBox::Attachment do + describe "#initialize" do + context "when neither :path or :content are given" do + it "should raise an ArgumentError" do + expect { + described_class.new + }.to raise_error(ArgumentError,"must specify :path or :content") + end + end + + context "when :path is given" do + let(:path) { __FILE__ } + + subject { described_class.new(path: path) } + + it "should set the path" do + expect(subject.path).to be path + end + + context "when :name is also given" do + let(:name) { "nudes.zip" } + + subject { described_class.new(path: path, name: name) } + + it "should override the default :name" do + expect(subject.name).to be name + end + end + + context "when :name is not given" do + it "should derive the name from the path" do + expect(subject.name).to be == File.basename(path) + end + end + + context "when :content is not given" do + it "should default to the files contents" do + expect(subject.content).to be == File.binread(path) + end + end + end + + context "when :content is given" do + context "when :name is not given" do + it "should raise an ArgumentError" do + expect { + described_class.new(content: "foo") + }.to raise_error(ArgumentError,":path or :name are required") + end + end + + context "when :name is given" do + let(:name) { "nudes.zip" } + let(:content) { "foo bar" } + + subject { described_class.new(name: name, content: content) } + + it "should set content and name" do + expect(subject.name).to be name + expect(subject.content).to be content + end + end + end + + context "when :encoding is not given" do + let(:name) { 'nudes.zip' } + let(:content) { "foo bar" } + let(:encoding) { 'zip' } + + subject { described_class.new(name: name, content: content) } + + it "should derive the encoding from the attachment name" do + expect(subject.encoding).to be == encoding + end + end + end + + describe "#to_liquid" do + let(:name) { 'nudes.zip' } + let(:encoding) { 'zip' } + let(:password) { "infected" } + + subject do + described_class.new( + name: name, + content: double, + encoding: encoding, + password: password + ).to_liquid + end + + it "should contain the name" do + expect(subject['name']).to be name + end + + it "should contain the encoding" do + expect(subject['encoding']).to be encoding + end + + it "should contain the password" do + expect(subject['password']).to be password + end + end +end diff --git a/spec/campaign_spec.rb b/spec/campaign_spec.rb new file mode 100644 index 0000000..e89620a --- /dev/null +++ b/spec/campaign_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' +require 'tackle_box/campaign' + +describe TackleBox::Campaign do + let(:organization) do + Organization.new( + 'Trail of Bits, LLC', + domain: 'trailofbits.com', + website: 'http://www.trailofbits.com/', + industry: 'Information Security' + ) + end + + let(:to) do + Person.new('dan@trailofbits.com', name: 'Dan Guido', + title: 'CEO', + department: 'Management', + location: 'New York, NY') + end + + let(:from) do + Person.new('nick@trailofberts.com', name: 'Nick De', + title: 'Chief Twitter Strategist', + department: 'Sales', + location: 'New York, NY') + end + + let(:email_template) do + Email::Template.open(File.join(File.dirname(__FILE__),'email','multipart_email_template.eml')) + end + + let(:link) { 'http://lurkdin.com/uas/request-password-reset?trk=signin_fpwd' } + + let(:attachment) do + { + name: 'nudes.jpg', + content: 'infected' + } + end + + subject do + described_class.new(email_template, organization, link: link, + account: from) + end + + describe "#initialize" do + context "when a link option is given" do + it "should initialize a Link object" do + expect(subject.link.to_s).to be == link + end + end + + context "when an attachment option is given" do + subject do + described_class.new(email_template,organization, link: link, + attachment: attachment, + account: from) + end + + it "should initialize an Attachment object" do + expect(subject.attachment.name).to be == attachment[:name] + expect(subject.attachment.content).to be == attachment[:content] + end + end + end + + describe "#to_liquid" do + let(:liquid) { subject.to_liquid } + + it "should contain the organization hash" do + expect(liquid['organization']).to be == organization.to_liquid + end + + context "when #link is set" do + it "should include a link hash" do + expect(liquid['link']).to be == subject.link.to_liquid + end + end + + context "when #attachment is set" do + subject do + described_class.new(email_template,organization, link: link, + attachment: attachment, + account: from) + end + + it "should include an attachment hash" do + expect(liquid['attachment']).to be == subject.attachment.to_liquid + end + end + end +end diff --git a/spec/email/editor_spec.rb b/spec/email/editor_spec.rb new file mode 100644 index 0000000..f9ba214 --- /dev/null +++ b/spec/email/editor_spec.rb @@ -0,0 +1,198 @@ +require 'spec_helper' +require 'tackle_box/email/editor' + +describe TackleBox::Email::Editor do + let(:email) { File.join(File.dirname(__FILE__),'email.eml') } + + subject { described_class.new(email) } + + describe "#initialize" do + it "should set path" do + expect(subject.path).to be email + end + + it "should parse the email" do + expect(subject.email.class).to be Mail::Message + expect(subject.email.to_s).to be == File.read(email) + end + end + + describe "#edit" do + it "should yield the email" do + expect { |b| subject.edit(&b) }.to yield_with_args(subject.email) + end + end + + describe "#edit_body_parts" do + it "should return an Enumerator if no block is given" do + expect(subject.edit_body_parts.class).to be Enumerator + end + + context "when the email multiple body parts" do + let(:email) { File.join(File.dirname(__FILE__),'multipart_email.eml') } + + it "should yield each text/plain and text/html part" do + expect { |b| + subject.edit_body_parts(&b) + }.to yield_successive_args(*subject.email.parts) + end + end + + context "when the email only has one body part" do + let(:email) { File.join(File.dirname(__FILE__),'email.eml') } + + it "should yield the email itself" do + expect { |b| subject.edit_body_parts(&b) }.to yield_with_args(subject.email) + end + end + end + + describe "#dequote" do + let(:email) do + File.join(File.dirname(__FILE__),'quoted_printable_email.eml') + end + + let(:unquoted_email) do + Mail.read(File.join(File.dirname(__FILE__),'email.eml')) + end + + before { subject.dequote } + + it "should set Content-Transfer-Encoding to '7bit'" do + expect(subject.email.content_transfer_encoding).to be == '7bit' + end + + it "should dequote the body" do + expect(subject.email.body.to_s).to be == unquoted_email.body.to_s + end + end + + describe "#replace" do + it "should find and replace text in the headers" do + subject.replace('Dan Guido','Nick De') + + expect(subject.email.header.raw_source).to_not include('Dan Guido') + end + + it "should find and replace text in the body" do + subject.replace('nudes','nudez') + + expect(subject.email.body.to_s).to_not include('nudes') + end + end + + describe "#replace_name" do + let(:old_first_name) { 'Hal' } + let(:old_last_name) { 'Brodigan' } + let(:old_name) { "#{old_first_name} #{old_last_name}" } + + let(:new_first_name) { 'Nick' } + let(:new_last_name) { 'Depetrillo' } + let(:new_name) { "#{new_first_name} #{new_last_name}" } + + before { subject.replace_name(old_name,new_name) } + + it "should replace all instances of the old name" do + expect(subject.email.to_s).to_not include(old_name) + expect(subject.email.to_s).to include(new_name) + end + + it "should replace all instances of the old last name" do + expect(subject.email.to_s).to_not include(old_last_name) + expect(subject.email.to_s).to include(new_last_name) + end + + it "should replace all instances of the old first name" do + expect(subject.email.to_s).to_not include(old_first_name) + expect(subject.email.to_s).to include(new_first_name) + end + end + + describe "#replace_from" do + let(:old_from) { 'hal@trailofbits.com' } + let(:new_from) { 'nick@trailofbits.com' } + + before { subject.replace_from(new_from) } + + it "should replace the from address" do + expect(subject.email.to_s).to_not include(old_from) + expect(subject.email.from).to include(new_from) + end + + context "with no argument" do + let(:new_from) { '{{ from.address }}' } + + before { subject.replace_from } + + it "should replace the from address with '{{ from.address }}'" do + expect(subject.email.to_s).to_not include(old_from) + expect(subject.email.from).to include(new_from) + end + end + end + + describe "#replace_from_name" do + let(:old_from_name) { 'Hal Brodigan' } + let(:new_from_name) { 'Nick De' } + + before { subject.replace_from_name(new_from_name) } + + it "should replace the from address" do + expect(subject.email.to_s).to_not include(old_from_name) + expect(subject.email.header[:from].address_list.addresses.first.display_name).to be == new_from_name + end + + context "with no argument" do + let(:new_from_name) { '{{ from.name }}' } + + before { subject.replace_from_name } + + it "should replace the from address with '{{ from.address }}'" do + expect(subject.email.to_s).to_not include(old_from_name) + expect(subject.email.header[:from].address_list.addresses.first.display_name).to be == new_from_name + end + end + end + + describe "#replace_to" do + let(:old_to) { 'dan@trailofbits.com' } + let(:new_to) { '{{ to.address }}' } + + before { subject.replace_to } + + it "should replace the to address with '{{ to.address }}'" do + expect(subject.email.to_s).to_not include(old_to) + expect(subject.email.to).to include(new_to) + end + end + + describe "#replace_to_name" do + let(:old_to_name) { 'Dan Guido' } + let(:new_to_name) { '{{ to.name }}' } + + before { subject.replace_to_name } + + it "should replace the to address with '{{ to.address }}'" do + expect(subject.email.to_s).to_not include(old_to_name) + expect(subject.email.header[:to].address_list.addresses.first.display_name).to be == new_to_name + end + end + + describe "#replace_domain" do + let(:old_domain) { 'imgur.com' } + let(:new_domain) { 'imdurr.com' } + + before { subject.replace_domain(old_domain,new_domain) } + + it "should replace every domain with the new domain" do + expect(subject.email.to_s).to_not include(old_domain) + expect(subject.email.to_s).to include(new_domain) + end + end + + describe "#to_s" do + it "should return the edited email" do + expect(subject.to_s).to be == subject.email.to_s + end + end +end diff --git a/spec/email/sender_spec.rb b/spec/email/sender_spec.rb new file mode 100644 index 0000000..d9dfb67 --- /dev/null +++ b/spec/email/sender_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' +require 'tackle_box/email/sender' + +describe TackleBox::Email::Sender do + let(:domains) do + { + 'lurkedin.com' => { + address: 'mail.lurkedin.com', + port: 587, + user_name: 'test', + password: 'test', + authentication: :plain, + enable_starttls_auto: true + }, + + 'trailofbits.com' => { + address: 'mail.trailofbits.com', + port: 587, + user_name: 'test', + password: 'test', + authentication: :plain, + enable_starttls_auto: true + } + } + end + + subject { described_class.new(domains) } + + describe "#initialize" do + it "should set domains" do + expect(subject.domains).to be domains + end + end + + describe "#send" do + let(:email) do + double('Mail::Message', from_addrs: ['hal@trailofbits.com']) + end + + let(:expected_credentials) { domains['trailofbits.com'] } + + before do + allow(email).to receive(:deliver) + end + + it "should select the credentials based on the from domain" do + expect(email).to receive(:delivery_method).with( + :smtp, expected_credentials + ) + + subject.send(email) + end + end +end diff --git a/spec/email/template/extensions/uri/http_spec.rb b/spec/email/template/extensions/uri/http_spec.rb new file mode 100644 index 0000000..75f5598 --- /dev/null +++ b/spec/email/template/extensions/uri/http_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +require 'tackle_box/email/template/extensions/uri/http' + +describe URI::HTTP do + let(:link) { "http://lurkdin.com/uas/request-password-reset?trk=signin_fpwd" } + let(:uri) { URI(link) } + + subject { uri } + + describe "#to_liquid" do + subject { super().to_liquid } + + it "should include 'url'" do + expect(subject['url']).to be == link + end + + [:scheme, :host, :port, :path, :query].each do |component| + it "should include '#{component}'" do + expect(subject[component.to_s]).to be == uri.send(component) + end + end + end +end diff --git a/spec/email/template/filters_spec.rb b/spec/email/template/filters_spec.rb new file mode 100644 index 0000000..a819376 --- /dev/null +++ b/spec/email/template/filters_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +require 'tackle_box/email/template/filters' + +describe TackleBox::Email::Template::Filters do + describe "lowercase" do + subject { Liquid::Template.parse('{{ "INPUT" | lowercase }}') } + + it "should downcase the input" do + expect(subject.render).to eq('input') + end + end + + describe "uppercase" do + subject { Liquid::Template.parse('{{ "input" | uppercase }}') } + + it "should upcase the input" do + expect(subject.render).to eq('INPUT') + end + end + + describe "capitalize" do + subject { Liquid::Template.parse('{{ "input" | capitalize }}') } + + it "should capitalize the input" do + expect(subject.render).to eq('Input') + end + end + + describe "pluralize" do + subject { Liquid::Template.parse('{{ "input" | pluralize }}') } + + it "should pluralize the input" do + expect(subject.render).to eq('inputs') + end + end + + describe "singularize" do + subject { Liquid::Template.parse('{{ "inputs" | singularize }}') } + + it "should singularize the input" do + expect(subject.render).to eq('input') + end + end +end diff --git a/spec/email/template/tags/attachment_spec.rb b/spec/email/template/tags/attachment_spec.rb new file mode 100644 index 0000000..2823a3f --- /dev/null +++ b/spec/email/template/tags/attachment_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +require 'tackle_box/email/template/tags/attachment' + +describe TackleBox::Email::Template::Tags::Attachment do + subject { Liquid::Template.parse('{% attachment %}') } + + let(:attachment) { 'foo.zip' } + + context "when 'attachment' is set" do + let(:context) do + Liquid::Context.new({'attachment' => {'name' => attachment}}) + end + + it "should add a random greeting" do + expect(subject.render(context)).to eq(attachment) + end + + context "when 'attachment_password' is also set" do + let(:attachment_password) { 'secret' } + let(:context) do + Liquid::Context.new({ + 'attachment' => { + 'name' => attachment, + 'password' => attachment_password + } + }) + end + + it "should add a random greeting" do + expect(subject.render(context)).to match(/^#{attachment} \(#{Regexp.union(described_class::TEXT)} #{attachment_password}\)$/) + end + end + end + + context "when 'attachment' is not set" do + let(:context) { Liquid::Context.new } + + it "should add a random greeting" do + expect(subject.render(context)).to eq('') + end + end +end diff --git a/spec/email/template/tags/date_spec.rb b/spec/email/template/tags/date_spec.rb new file mode 100644 index 0000000..28eaf16 --- /dev/null +++ b/spec/email/template/tags/date_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +require 'tackle_box/email/template/tags/date' + +describe TackleBox::Email::Template::Tags::Date do + subject { Liquid::Template.parse('{% date %}') } + + it "should render the current date" do + expect(subject.render).to eq(Date.today.strftime("%b %e")) + end +end diff --git a/spec/email/template/tags/day_spec.rb b/spec/email/template/tags/day_spec.rb new file mode 100644 index 0000000..49890f2 --- /dev/null +++ b/spec/email/template/tags/day_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +require 'tackle_box/email/template/tags/day' + +describe TackleBox::Email::Template::Tags::Day do + subject { Liquid::Template.parse('{% day %}') } + + it "should render the current day" do + expect(subject.render).to eq("#{Date.today.day}") + end +end diff --git a/spec/email/template/tags/link_spec.rb b/spec/email/template/tags/link_spec.rb new file mode 100644 index 0000000..93412cf --- /dev/null +++ b/spec/email/template/tags/link_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +require 'tackle_box/email/template/tags/link' + +describe TackleBox::Email::Template::Tags::Link do + let(:template) { Liquid::Template.parse('{% link %}') } + + let(:link) { 'http://apt.trailofbits.com/uas/request-password-reset?trk=signin_fpwd' } + let(:url) { URI(link) } + + let(:param) { described_class::TOKEN_PARAM } + let(:token) { 'jkfsjls' } + + context "when 'link' is set" do + let(:context) do + Liquid::Context.new({ + 'token' => token, + 'link' => { + 'scheme' => url.scheme, + 'host' => url.host, + 'port' => url.port, + 'path' => url.path, + 'query' => url.query + } + }) + end + + subject { template.render(context) } + + it "should add the original link" do + expect(subject).to include(link) + end + + context "when the link has existing query params" do + it "should append the tracking query parameter and recipient token" do + expect(subject).to include("&#{param}=#{token}") + end + end + + context "when the link does not have query params" do + before { url.query = nil } + + it "should set the tracking query parameter and recipient token" do + expect(subject).to include("?#{param}=#{token}") + end + end + + context "when link_shortener is set" do + before { context['link_shortener'] = 'tinyurl' } + + it "should shorten the resulting link" do + expect(subject).to include("http://tinyurl.com/") + end + end + end + + context "when 'link' is not set" do + let(:context) { Liquid::Context.new } + + subject { template.render(context) } + + it "should add a random greeting" do + expect(subject).to be == '' + end + end +end diff --git a/spec/email/template/tags/random_greeting_spec.rb b/spec/email/template/tags/random_greeting_spec.rb new file mode 100644 index 0000000..c9505dd --- /dev/null +++ b/spec/email/template/tags/random_greeting_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +require 'tackle_box/email/template/tags/random_greeting' + +describe TackleBox::Email::Template::Tags::RandomGreeting do + subject { Liquid::Template.parse('{% random_greeting %}') } + + it "should add a random greeting" do + expect(subject.render).not_to be_empty + end +end diff --git a/spec/email/template/tags/week_day_spec.rb b/spec/email/template/tags/week_day_spec.rb new file mode 100644 index 0000000..c7f93c7 --- /dev/null +++ b/spec/email/template/tags/week_day_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' +require 'tackle_box/email/template/tags/week_day' + +describe TackleBox::Email::Template::Tags::WeekDay do + subject { Liquid::Template.parse('{% week_day %}') } + + let(:week_days) { %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday] } + + it "should render the current week day" do + expect(subject.render).to eq(week_days[Date.today.wday]) + end +end diff --git a/spec/email/template/tags/year_spec.rb b/spec/email/template/tags/year_spec.rb new file mode 100644 index 0000000..6081124 --- /dev/null +++ b/spec/email/template/tags/year_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +require 'tackle_box/email/template/tags/year' + +describe TackleBox::Email::Template::Tags::Year do + subject { Liquid::Template.parse('{% year %}') } + + it "should render the current year" do + expect(subject.render).to eq("#{Date.today.year}") + end +end diff --git a/spec/email/template_spec.rb b/spec/email/template_spec.rb new file mode 100644 index 0000000..ff02e8f --- /dev/null +++ b/spec/email/template_spec.rb @@ -0,0 +1,125 @@ +require 'spec_helper' + +require 'tackle_box/email/template' + +describe TackleBox::Email::Template do + let(:file) { File.join(File.dirname(__FILE__),'email_template.eml') } + + describe ".open" do + subject { described_class.open(file) } + + let(:contents) { File.read(file) } + + it "should load the contents of the file" do + expect(subject.email).to be == contents + end + end + + subject { described_class.open(file) } + + describe "#requires_to?" do + it "should check for '{{ to.'" do + expect(subject.email).to receive(:include?).with('{{ to.').and_return(true) + + subject.requires_to? + end + end + + describe "#requires_from?" do + it "should check for '{{ from.'" do + expect(subject.email).to receive(:include?).with('{{ from.').and_return(true) + + subject.requires_from? + end + end + + describe "#requires_link?" do + it "should check for '{% link %}' first" do + expect(subject.email).to receive(:include?).with('{% link %}').and_return(true) + + subject.requires_link? + end + end + + describe "#requires_attachment?" do + it "should check for '{% attachment %}' first" do + expect(subject.email).to receive(:include?).with('{% attachment %}').and_return(false) + expect(subject.email).to receive(:include?).with('{{ attachment.').and_return(false) + + subject.requires_attachment? + end + end + + describe "#render" do + subject { super().render(variables) } + + let(:name) { "Dan Guido" } + let(:token) { 'jfsjlfsjl' } + let(:link) { 'http://lurkdin.com/uas/request-password-reset?trk=signin_fpwd' } + + let(:variables) do + { + 'to' => { + 'name' => name, + 'address' => 'dan@trailofbits.com' + }, + 'from' => { + 'name' => 'Nick De', + 'address' => 'nick@trailofberts.com' + }, + 'token' => token, + 'link' => URI(link) + } + end + + let(:expected_html) do + %{ + + + + test email + + + Checkout these nudes.
+ + + }.strip << "\n" + end + + let(:expected_to) do + "#{variables['to']['name']} <#{variables['to']['address']}>" + end + + let(:expected_from) do + "#{variables['from']['name']} <#{variables['from']['address']}>" + end + + let(:expected_subject) { "Hello, #{name}" } + + it "should render the headers" do + expect(subject.header[:to].value).to be == expected_to + expect(subject.header[:from].value).to be == expected_from + expect(subject.header[:subject].value).to be == expected_subject + end + + it "should render the body" do + expect(subject.body.decoded).to be == expected_html + end + + context "when the email template is multi-part" do + let(:file) { File.join(File.dirname(__FILE__),'multipart_email_template.eml') } + + let(:expected_txt) do + %{Checkout these nudes (#{link}&token=#{token}).} + end + + it "should render any text/plain body parts" do + expect(subject.body.parts[0].decoded).to be == expected_txt + end + + it "should render any text/html body parts" do + expect(subject.body.parts[1].decoded).to be == expected_html + end + end + end +end diff --git a/spec/link_spec.rb b/spec/link_spec.rb new file mode 100644 index 0000000..4919717 --- /dev/null +++ b/spec/link_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' +require 'tackle_box/link' + +describe TackleBox::Link do + let(:scheme) { 'http' } + let(:host) { "www.linkedin.com" } + let(:port) { 80 } + let(:path) { "/uas/request-password-reset" } + let(:query) { "trk=signin_fpwd" } + let(:url) { URI::HTTP.build([nil, host, port, path, query, nil]) } + + subject { described_class.new(url) } + + describe "#domain" do + it "should return the host from the link" do + expect(subject.domain).to be == host + end + end + + describe "#to_liquid" do + subject { super().to_liquid } + + it "should contain the full url" do + expect(subject['url']).to be == url.to_s + end + + it "should contain a scheme" do + expect(subject['scheme']).to be == scheme + end + + it "should contain a host" do + expect(subject['host']).to be == host + end + + it "should contain a port" do + expect(subject['port']).to be == port + end + + it "should contain a path" do + expect(subject['path']).to be == path + end + + it "should contain a query" do + expect(subject['query']).to be == query + end + end + + describe "#to_s" do + it "should return the full link" do + expect(subject.to_s).to be == url.to_s + end + end +end diff --git a/spec/organization_spec.rb b/spec/organization_spec.rb new file mode 100644 index 0000000..a4a757b --- /dev/null +++ b/spec/organization_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +require 'tackle_box/organization' + +describe TackleBox::Organization do + let(:name) { 'Trail of Bits, LLC' } + let(:domain) { 'trailofbits.com' } + let(:website) { 'http://www.trailofbits.com/' } + let(:industry) { 'Information Security' } + + subject do + described_class.new(name, domain: domain, + website: website, + industry: industry) + end + + describe "#initialize" do + subject { described_class.new(name) } + + it "should set name" do + expect(subject.name).to be name + end + + context "when domain is given" do + subject do + described_class.new(name, domain: domain) + end + + it "should set domain" do + expect(subject.domain).to be domain + end + end + + context "when website is given" do + subject do + described_class.new(name, website: website) + end + + it "should set website" do + expect(subject.website).to be website + end + end + + context "when website is omitted" do + context "when domain is given" do + subject do + described_class.new(name, domain: domain) + end + + it "should derive the website from the domain" do + expect(subject.website).to be == "http://#{domain}" + end + end + + context "when domain is also omitted" do + it "should be nil" do + expect(subject.website).to be_nil + end + end + end + end + + describe "#person" do + let(:address) { 'dan@trailofbits.com' } + let(:name) { 'Dan Guido' } + let(:title) { 'CEO' } + + before do + subject.person(address, name: name, title: title) + end + + let(:person) { subject.people.last } + + it "should add a person to the organization" do + expect(person).to_not be_nil + expect(person.address).to be == address + expect(person.name).to be == name + expect(person.title).to be == title + end + end + + describe "#to_liquid" do + subject { super().to_liquid } + + it "should have a 'name'" do + expect(subject['name']).to be == name + end + + it "should have a 'domain'" do + expect(subject['domain']).to be == domain + end + + it "should have an 'industry'" do + expect(subject['industry']).to be == industry + end + + it "should have a 'website'" do + expect(subject['website']).to be == website + end + end + + describe "#to_s" do + subject { super().to_s } + + it "should return the name" do + expect(subject).to be == name + end + end +end diff --git a/spec/person_spec.rb b/spec/person_spec.rb new file mode 100644 index 0000000..94912fb --- /dev/null +++ b/spec/person_spec.rb @@ -0,0 +1,110 @@ +require 'spec_helper' + +require 'tackle_box/person' + +describe TackleBox::Person do + let(:name) { "Dan Guido" } + let(:address) { "dan@trailofbits.com" } + let(:title) { "CEO" } + let(:department) { "Management" } + let(:location) { 'New York, NY' } + + subject do + described_class.new(address, name: name, + title: title, + department: department, + location: location) + end + + describe "#initialize" do + subject do + described_class.new(address, name: name) + end + + it "should set address" do + expect(subject.address).to be address + end + + context "when name is given" do + subject do + described_class.new(address,name: name) + end + + it "should set name" do + expect(subject.name).to be name + end + end + + context "when title is given" do + subject do + described_class.new(address,title: title) + end + + it "should set title" do + expect(subject.title).to be title + end + end + + context "when department is given" do + subject do + described_class.new(address,department: department) + end + + it "should set department" do + expect(subject.department).to be department + end + end + + context "when location is given" do + subject do + described_class.new(address,location: location) + end + + it "should set location" do + expect(subject.location).to be location + end + end + end + + describe "#to_liquid" do + subject { super().to_liquid } + + it "should include name" do + expect(subject['name']).to be == name + end + + it "should include address" do + expect(subject['address']).to be == address + end + + it "should include title" do + expect(subject['title']).to be == title + end + + it "should include department" do + expect(subject['department']).to be == department + end + + it "should include the location" do + expect(subject['location']).to be == location + end + end + + describe "#to_s" do + context "when name is omitted" do + subject { described_class.new(address) } + + it "should return the address" do + expect(subject.to_s).to be == address + end + end + + context "when #name is set" do + subject { described_class.new(address, name: name) } + + it "should == 'name
'" do + expect(subject.to_s).to be == "#{name} <#{address}>" + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f860f95 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,4 @@ +require 'rspec' +require 'tackle_box/version' + +include TackleBox diff --git a/spec/tackle_box_spec.rb b/spec/tackle_box_spec.rb new file mode 100644 index 0000000..0d07891 --- /dev/null +++ b/spec/tackle_box_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' +require 'tackle_box' + +describe TackleBox do + it "should have a VERSION constant" do + expect(subject.const_get('VERSION')).not_to be_empty + end +end diff --git a/tackle_box.gemspec b/tackle_box.gemspec new file mode 100644 index 0000000..2c48b5c --- /dev/null +++ b/tackle_box.gemspec @@ -0,0 +1,28 @@ +# -*- encoding: utf-8 -*- + +require File.expand_path('../lib/tackle_box/version', __FILE__) + +Gem::Specification.new do |gem| + gem.name = "tackle_box" + gem.version = TackleBox::VERSION + gem.summary = %q{Phishing toolkit} + gem.description = %q{A phishing toolkit for generating and sending phishing emails} + gem.license = "MIT" + gem.authors = ["Hal Brodigan"] + gem.email = "hal@trailofbits.com" + gem.homepage = "https://github.com/trailofbits/tackle_box" + + gem.files = `git ls-files`.split($/) + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.require_paths = ['lib'] + + gem.add_dependency 'activesupport', '~> 3.2' + gem.add_dependency 'shorturl', '~> 1.0' + gem.add_dependency 'liquid', '~> 2.4' + gem.add_dependency 'nokogiri', '~> 1.6' + gem.add_dependency 'chars', '~> 0.2' + gem.add_dependency 'mail', '~> 2.5' + + gem.add_development_dependency 'bundler', '~> 1.0' +end