patch_finder 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c3c8d250a7aa94b35b8f5aec0c944eac2c0a3935
4
+ data.tar.gz: 9c92cbd76ea88c27e15fcbecf668a88a645e53bf
5
+ SHA512:
6
+ metadata.gz: 2a55fda811184d47d0ae322d70a5b3431192f3fe9c93ad8588a48031ecd95af6a97f9499caa0d194b4f22812a4f6d6f5e908081d2db36e2c79ed08d95f28b32d
7
+ data.tar.gz: bb45dcb3fed06f018861ae979f0dc362e8a838c825d3bbe1866ef86003d4244e764f9ed01c238810052c3286478fa249acb85faab5d5dbce6f03a56544f0ebc0
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # PatchFinder
2
+
3
+ PatchFinder is a toolkit to find software patches.
4
+
5
+ All download clients can be found in the bin directory. Each client is meant for a specific
6
+ type of patch. For example: msu_finder is used to find Microsoft updates (.msu).
7
+
8
+ For other tools that might be useful to examine the patches, please see the tools directory.
9
+
10
+ ## Installation
11
+
12
+ To install PatchFinder:
13
+
14
+ ```
15
+ $ gem install patch_finder
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ Each patch finding client can be found in the bin directory. To learn how to use one, please refer
21
+ to its ```-h``` option, or the [wiki page](https://github.com/wchen-r7/Patch-Finder/wiki).
22
+
23
+ ## License
24
+
25
+ [BSD-3-Clause](https://opensource.org/licenses/BSD-3-Clause)
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/msu_finder ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ ##
4
+ #
5
+ # This tool allows you to collect Microsoft patches.
6
+ # Once you have downloaded all the .msu patches, you can use tools/extract_msu.bat to
7
+ # automatically extract them.
8
+ #
9
+ ##
10
+
11
+ lib = File.expand_path('../../lib', __FILE__)
12
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
13
+
14
+ require 'core/helper'
15
+ require 'core/config'
16
+ require 'optparse'
17
+ require 'msu'
18
+
19
+ class PatchFinderBin
20
+
21
+ include PatchFinder::Helper
22
+
23
+ attr_reader :args
24
+
25
+ def banner
26
+ @banner ||= lambda {
27
+ doc_path = File.join(PatchFinder::Config.doc_directory, 'msu_finder.txt')
28
+ read_file(doc_path)
29
+ }.call
30
+ end
31
+
32
+ def get_parsed_options
33
+ options = {}
34
+
35
+ parser = OptionParser.new do |opt|
36
+ opt.banner = banner
37
+ opt.separator ''
38
+ opt.separator 'Specific options:'
39
+
40
+ opt.on('-q', '--query <keyword>', 'Find advisories including this keyword') do |v|
41
+ options[:keyword] = v
42
+ end
43
+
44
+ opt.on('-s', '--search-engine <engine>', '(Optional) The type of search engine to use (Technet or Google). Default: Technet') do |v|
45
+ case v.to_s
46
+ when /^google$/i
47
+ options[:search_engine] = :google
48
+ when /^technet$/i
49
+ options[:search_engine] = :technet
50
+ else
51
+ fail OptionParser::InvalidOption, "Invalid search engine: #{v}"
52
+ end
53
+ end
54
+
55
+ opt.on('-r', '--regex <string>', '(Optional) Specify what type of links you want') do |v|
56
+ options[:regex] = v
57
+ end
58
+
59
+ opt.on('--apikey <key>', '(Optional) Google API key.') do |v|
60
+ options[:google_api_key] = v
61
+ end
62
+
63
+ opt.on('--cx <id>', '(Optional) Google search engine ID.') do |v|
64
+ options[:google_search_engine_id] = v
65
+ end
66
+
67
+ opt.on('-d', '--dir <string>', '(Optional) The directory to save the patches') do |v|
68
+ unless File.directory?(v)
69
+ fail OptionParser::InvalidOption, "Directory not found: #{v}"
70
+ end
71
+
72
+ options[:destdir] = v
73
+ end
74
+
75
+ opt.on_tail('-h', '--help', 'Show this message') do
76
+ $stderr.puts opt
77
+ exit
78
+ end
79
+ end
80
+
81
+ parser.parse!
82
+
83
+ if options.empty?
84
+ fail OptionParser::MissingArgument, 'No options set, try -h for usage'
85
+ elsif options[:keyword].nil? || options[:keyword].empty?
86
+ fail OptionParser::MissingArgument, '-q is required'
87
+ end
88
+
89
+ unless options[:search_engine]
90
+ options[:search_engine] = :technet
91
+ end
92
+
93
+ if options[:search_engine] == :google
94
+ if options[:google_api_key].nil? || options[:google_search_engine_id].empty?
95
+ fail OptionParser::MissingArgument, 'No API key set for Google'
96
+ elsif options[:google_search_engine_id].nil? || options[:google_search_engine_id].empty?
97
+ fail OptionParser::MissingArgument, 'No search engine ID set for Google'
98
+ end
99
+ end
100
+
101
+ options
102
+ end
103
+
104
+ def initialize
105
+ @args = get_parsed_options
106
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
107
+ print_error(e.message)
108
+ exit
109
+ end
110
+
111
+ def main
112
+ cli = PatchFinder::MSU.new
113
+ links = cli.find_msu_download_links(args)
114
+ if args[:destdir]
115
+ print_status("Download links found: #{links.length}")
116
+ print_status('Downloading files, please wait...')
117
+ download_files(links, args[:destdir])
118
+ else
119
+ print_status('Download links found:')
120
+ print_line(links * "\n")
121
+ end
122
+ end
123
+ end
124
+
125
+ bin = PatchFinderBin.new
126
+ bin.main
@@ -0,0 +1,38 @@
1
+ Usage: msu_finder [options]
2
+
3
+ The following example will download all IE updates to directory /tmp:
4
+ msu_finder -q "Internet Explorer" -d /tmp/
5
+
6
+ Searching advisories via Technet:
7
+
8
+ When you submit a query, the Technet search engine will first look it up from a product list,
9
+ and then return all the advisories that include the keyword you are looking for. If there's
10
+ no match from the product list, then the script will try a generic search. The generic method
11
+ also means you can search by MSB, KB, or even the CVE number.
12
+
13
+ Searching advisories via Google:
14
+
15
+ Searching via Google requires an API key and an Search Engine ID from Google. To obtain these,
16
+ make sure you have a Google account (such as Gmail), and then do the following:
17
+
18
+ 1. Go to Google Developer's Console
19
+ 1. Enable Custom Search API
20
+ 2. Create a browser type credential. The credential is the API key.
21
+ 2. Go to Custom Search
22
+ 1. Create a new search engine
23
+ 2. Under Sites to Search, set: technet.microsoft.com
24
+ 3. In your search site, get the Search Engine ID under the Basics tab.
25
+
26
+ By default, Google has a quota limit of 1000 queries per day. You can raise this limit with
27
+ a fee.
28
+
29
+ The way this tool uses Google to find advisories is the same as doing the following manually:
30
+ [Query] site:technet.microsoft.com intitle:"Microsoft Security Bulletin" -"Microsoft Security Bulletin Summary"
31
+
32
+ Dryrun mode:
33
+ Without the -d flag (destination), the tool will not download the patches, it will just find you
34
+ the download links.
35
+
36
+ Patch Extraction:
37
+ After downloading the patch, you can use the extract_msu.bat tool to automatically extract
38
+ Microsoft patches.
@@ -0,0 +1,19 @@
1
+ module PatchFinder
2
+ module Config
3
+
4
+ # Returns the doc directory.
5
+ #
6
+ # @return [String]
7
+ def self.doc_directory
8
+ @doc_directory ||= File.expand_path(File.join(root_directory, '..', 'docs', 'bin'))
9
+ end
10
+
11
+ # Returns the root directory.
12
+ #
13
+ # @return [String]
14
+ def self.root_directory
15
+ @root_directory ||= File.expand_path(File.join(File.dirname(__FILE__), '..'))
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,158 @@
1
+ require 'net/http'
2
+
3
+ module PatchFinder
4
+ module Helper
5
+
6
+ class PatchFinderException < RuntimeError; end
7
+
8
+ # Prints a debug message.
9
+ #
10
+ # @return [void]
11
+ def print_debug(msg = '')
12
+ $stderr.puts "[DEBUG] #{msg}"
13
+ end
14
+
15
+ # Prints a status message.
16
+ #
17
+ # @return [void]
18
+ def print_status(msg = '')
19
+ $stderr.puts "[*] #{msg}"
20
+ end
21
+
22
+ # Prints an error message.
23
+ #
24
+ # @return [void]
25
+ def print_error(msg = '')
26
+ $stderr.puts "[ERROR] #{msg}"
27
+ end
28
+
29
+ # Prints a message.
30
+ #
31
+ # @return [void]
32
+ def print_line(msg = '')
33
+ $stdout.puts msg
34
+ end
35
+
36
+ # Sends an HTTP request.
37
+ # @note If the request fails, it will try 3 times before
38
+ # passing/raising an exception.
39
+ #
40
+ # @param uri [String] URI.
41
+ # @param ssl [Boolean] Forces SSL option.
42
+ # @return [Net::HTTPResponse]
43
+ def send_http_get_request(uri, ssl = false)
44
+ attempts = 1
45
+ u = URI.parse(uri)
46
+ res = nil
47
+ ssl = u.scheme == 'https' ? true : false
48
+
49
+ begin
50
+ Net::HTTP.start(u.host, u.port, use_ssl: ssl) do |cli|
51
+ req = Net::HTTP::Get.new(normalize_uri(u.request_uri))
52
+ req['Host'] = u.host
53
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
54
+ res = cli.request(req)
55
+ end
56
+ rescue Net::ReadTimeout, IOError, EOFError, Errno::ECONNRESET,
57
+ Errno::ECONNABORTED, Errno::EPIPE, Net::OpenTimeout,
58
+ Errno::ETIMEDOUT => e
59
+ if attempts < 3
60
+ sleep(5)
61
+ attempts += 1
62
+ retry
63
+ else
64
+ raise e
65
+ end
66
+ end
67
+
68
+ res
69
+ end
70
+
71
+ # Returns the content of a file.
72
+ #
73
+ # @param file_path [String] File to read.
74
+ # @return [String]
75
+ def read_file(file_path)
76
+ return nil unless File.exist?(file_path)
77
+
78
+ buf = ''
79
+
80
+ File.open(file_path, 'rb') do |f|
81
+ buf = f.read
82
+ end
83
+
84
+ buf
85
+ end
86
+
87
+ # Downloads a file to a local directory.
88
+ # @note When the file is saved, the file name will actually include a timestamp
89
+ # in this format: [original filename]_[timestamp].[ext] to avoid
90
+ # name collision.
91
+ #
92
+ # @param uri [String] URI (to download)
93
+ # @param dest_dir [String] The folder to save the file.
94
+ # Make sure this folder exists.
95
+ # @return [void]
96
+ def download_file(uri, dest_dir)
97
+ begin
98
+ u = URI.parse(uri)
99
+ fname, ext = File.basename(u.path).scan(/(.+)\.(.+)/).flatten
100
+ dest_file = File.join(dest_dir, "#{fname}_#{Time.now.to_i}.#{ext}")
101
+ res = send_http_get_request(uri)
102
+ rescue Net::ReadTimeout, IOError, EOFError, Errno::ECONNRESET,
103
+ Errno::ECONNABORTED, Errno::EPIPE, Net::OpenTimeout,
104
+ Errno::ETIMEDOUT => e
105
+ print_error("#{e.message}: #{uri}")
106
+ return
107
+ end
108
+
109
+ save_file(res.body, dest_file)
110
+ print_status("Download completed for #{uri}")
111
+ end
112
+
113
+ # Downloads multiple files to a local directory.
114
+ # @note 3 clients are used to download all the links.
115
+ #
116
+ # @param files [Array] Full URIs.
117
+ # @param dest_dir [String] The folder to save the files.
118
+ # Make sure this folder exists.
119
+ # @return pvoid
120
+ def download_files(files, dest_dir)
121
+ pool = PatchFinder::ThreadPool.new(3)
122
+
123
+ files.each do |f|
124
+ pool.schedule do
125
+ download_file(f, dest_dir)
126
+ end
127
+ end
128
+
129
+ pool.shutdown
130
+
131
+ sleep(0.5) until pool.eop?
132
+ end
133
+
134
+ private
135
+
136
+ # Saves a file to a specific location.
137
+ #
138
+ # @param data [String]
139
+ # @param dest_file [String]
140
+ # @return [void]
141
+ def save_file(data, dest_file)
142
+ File.open(dest_file, 'wb') do |f|
143
+ f.write(data)
144
+ end
145
+ end
146
+
147
+ # Returns the normalized URI by modifying the double slashes.
148
+ #
149
+ # @param strs [Array] URI path.
150
+ # @return [String]
151
+ def normalize_uri(*strs)
152
+ new_str = strs * '/'
153
+ new_str = new_str.gsub!('//', '/') while new_str.index('//')
154
+ new_str
155
+ end
156
+
157
+ end
158
+ end
@@ -0,0 +1,61 @@
1
+ module PatchFinder
2
+ class ThreadPool
3
+
4
+ attr_accessor :mutex
5
+
6
+ # Initializes the pool.
7
+ #
8
+ # @param size [Fixnum] Max number of threads to be running at the same time.
9
+ # @return [void]
10
+ def initialize(size)
11
+ @size = size
12
+ @mutex = Mutex.new
13
+ @jobs = Queue.new
14
+ @pool = Array.new(@size) do |i|
15
+ Thread.new do
16
+ Thread.current[:id] = i
17
+ catch(:exit) do
18
+ loop do
19
+ job, args = @jobs.pop
20
+ job.call(*args)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ # Adds a job to the queue.
28
+ #
29
+ # @param args [Array] Arguments.
30
+ # @param block [Proc] Code.
31
+ def schedule(*args, &block)
32
+ @jobs << [block, args]
33
+ end
34
+
35
+ # Shuts down all the jobs.
36
+ #
37
+ # @return [void]
38
+ def shutdown
39
+ @size.times do
40
+ schedule { throw :exit }
41
+ end
42
+
43
+ @pool.map(&:join)
44
+ end
45
+
46
+ # Returns whether there's anything in the queue left.
47
+ #
48
+ # @return [boolean]
49
+ def eop?
50
+ @jobs.empty?
51
+ end
52
+
53
+ end
54
+ end
55
+
56
+ =begin
57
+
58
+ Nothings that might not work so great in the pool:
59
+ * Timeout
60
+
61
+ =end
@@ -0,0 +1,37 @@
1
+ module PatchFinder
2
+ module Engine
3
+ module MSU
4
+
5
+ MICROSOFT = 'https://www.microsoft.com'
6
+ DOWNLOAD_MSFT = 'https://download.microsoft.com'
7
+ TECHNET = 'https://technet.microsoft.com'
8
+
9
+ # These pattern checks need to be in this order.
10
+ ADVISORY_PATTERNS = [
11
+ # This works from MS14-001 until the most recent
12
+ {
13
+ check: '//div[@id="mainBody"]//div//h2//div//span[contains(text(), "Affected Software")]',
14
+ pattern: '//div[@id="mainBody"]//div//div[@class="sectionblock"]//table//a'
15
+ },
16
+ # This works from ms03-040 until MS07-029
17
+ {
18
+ check: '//div[@id="mainBody"]//ul//li//a[contains(text(), "Download the update")]',
19
+ pattern: '//div[@id="mainBody"]//ul//li//a[contains(text(), "Download the update")]'
20
+ },
21
+ # This works from sometime until ms03-039
22
+ {
23
+ check: '//div[@id="mainBody"]//div//div[@class="sectionblock"]//p//strong[contains(text(), "Download locations")]',
24
+ pattern: '//div[@id="mainBody"]//div//div[@class="sectionblock"]//ul//li//a'
25
+ },
26
+ # This works from MS07-030 until MS13-106 (the last update in 2013)
27
+ # The check is pretty short so if it kicks in too early, it tends to create false positives.
28
+ # So it goes last.
29
+ {
30
+ check: '//div[@id="mainBody"]//p//strong[contains(text(), "Affected Software")]',
31
+ pattern: '//div[@id="mainBody"]//table//a'
32
+ }
33
+ ]
34
+
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,141 @@
1
+ require 'json'
2
+ require 'core/helper'
3
+ require_relative 'constants'
4
+
5
+ module PatchFinder
6
+ module Engine
7
+ module MSU
8
+
9
+ class GoogleClientException < RuntimeError ; end
10
+
11
+ class Google
12
+
13
+ include Helper
14
+
15
+ GOOGLEAPIS = 'https://www.googleapis.com'
16
+
17
+ attr_reader :api_key
18
+ attr_reader :search_engine_id
19
+
20
+ # API Doc:
21
+ # https://developers.google.com/custom-search/json-api/v1/using_rest
22
+ # Known bug:
23
+ # * Always gets 20 MSB results. Weird.
24
+
25
+ # Initializes the Google API client.
26
+ #
27
+ # @param opts [Hash]
28
+ # @option opts [String] :api_key Google API key.
29
+ # @option opts [String] :search_engine_id Google Search engine ID.
30
+ def initialize(opts = {})
31
+ @api_key = opts[:api_key]
32
+ @search_engine_id = opts[:search_engine_id]
33
+ end
34
+
35
+ # Returns the MSB (advisories) numbers for a search keyword.
36
+ #
37
+ # @param keyword [String]
38
+ # @return [Array]
39
+ def find_msb_numbers(keyword)
40
+ msb_numbers = []
41
+ next_starting_index = 1
42
+ search_opts = {
43
+ keyword: keyword,
44
+ starting_index: next_starting_index
45
+ }
46
+
47
+ begin
48
+ while
49
+ results = search(search_opts)
50
+ items = results['items']
51
+ items.each do |item|
52
+ title = item['title']
53
+ msb = title.scan(/Microsoft Security Bulletin (MS\d\d\-\d\d\d)/).flatten.first
54
+ msb_numbers << msb.downcase if msb
55
+ end
56
+
57
+ next_starting_index = get_next_index(results)
58
+ next_page = results['queries']['nextPage']
59
+
60
+ # Google API Documentation:
61
+ # https://developers.google.com/custom-search/json-api/v1/using_rest
62
+ # "This role is not present if the current results are the last page.
63
+ # Note: This API returns up to the first 100 results only."
64
+ break if next_page.nil? || next_starting_index > 100
65
+ end
66
+ rescue GoogleClientException => e
67
+ print_error(e.message)
68
+ return msb_numbers.uniq
69
+ end
70
+
71
+ msb_numbers.uniq
72
+ end
73
+
74
+ # Searches the Google API.
75
+ #
76
+ # @param opts [Hash]
77
+ # @option opts [Fixnum] :starting_index
78
+ # @option opts [String] :keyword
79
+ # @return [Hash] JSON
80
+ def search(opts = {})
81
+ starting_index = opts[:starting_index]
82
+
83
+ search_string = URI.escape([
84
+ opts[:keyword],
85
+ 'intitle:"Microsoft Security Bulletin"',
86
+ '-"Microsoft Security Bulletin Summary"'
87
+ ].join(' '))
88
+
89
+ req_str = "#{GOOGLEAPIS}/customsearch/v1?"
90
+ req_str << "key=#{api_key}&"
91
+ req_str << "cx=#{search_engine_id}&"
92
+ req_str << "q=#{search_string}&"
93
+ req_str << "start=#{starting_index.to_s}&"
94
+ req_str << 'num=10&'
95
+ req_str << 'c2coff=1'
96
+
97
+ res = send_http_get_request(req_str)
98
+ results = parse_results(res)
99
+ if starting_index == 1
100
+ print_status("Number of search results: #{get_total_results(results)}")
101
+ end
102
+
103
+ results
104
+ end
105
+
106
+ # Returns the string data to JSON.
107
+ #
108
+ # @raise [GoogleClientException] The Google Search API returns an error.
109
+ # @param res [Net::HTTPResponse]
110
+ def parse_results(res)
111
+ j = JSON.parse(res.body)
112
+
113
+ if j['error']
114
+ message = j['error']['errors'].first['message']
115
+ reason = j['error']['errors'].first['reason']
116
+ fail GoogleClientException, "Google Search failed. #{message} (#{reason})"
117
+ end
118
+
119
+ j
120
+ end
121
+
122
+ # Returns totalResults
123
+ #
124
+ # @param j [Hash] JSON response.
125
+ # @return [Fixnum]
126
+ def get_total_results(j)
127
+ j['queries']['request'].first['totalResults'].to_i
128
+ end
129
+
130
+ # Returns startIndex
131
+ #
132
+ # @param j [Hash] JSON response.
133
+ # @return [Fixnum]
134
+ def get_next_index(j)
135
+ j['queries']['nextPage'] ? j['queries']['nextPage'].first['startIndex'] : 0
136
+ end
137
+
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,115 @@
1
+ require 'json'
2
+ require 'nokogiri'
3
+ require 'core/helper'
4
+ require_relative 'constants'
5
+
6
+ module PatchFinder
7
+ module Engine
8
+ module MSU
9
+ class Technet
10
+
11
+ include Helper
12
+
13
+ # Initializes the Technet client.
14
+ #
15
+ # @return [void]
16
+ def initialize
17
+ @firstpage ||= lambda {
18
+ uri = '/en-us/security/bulletin/dn602597.aspx'
19
+ res = send_http_get_request("#{TECHNET}#{uri}")
20
+ return res.body
21
+ }.call
22
+ end
23
+
24
+ # Returns the MSB (advisories) numbers for a search keyword.
25
+ #
26
+ # @param keyword [String]
27
+ # @return [Array]
28
+ def find_msb_numbers(keyword)
29
+ product_list_matches = get_product_dropdown_list.select { |p| Regexp.new(keyword) === p[:option_text] }
30
+ if product_list_matches.empty?
31
+ print_status('No match from the product list, attempting a generic search')
32
+ search_by_keyword(keyword)
33
+ else
34
+ product_names = []
35
+ ids = []
36
+ product_list_matches.each do |e|
37
+ ids << e[:option_value]
38
+ product_names << e[:option_text]
39
+ end
40
+ print_status("Matches from the product list (#{product_names.length}): #{ product_names * ', ' }")
41
+ search_by_product_ids(ids)
42
+ end
43
+ end
44
+
45
+ # Searches the Technet engine.
46
+ #
47
+ # @param keyword [String]
48
+ # @return [Hash] JSON
49
+ def search(keyword)
50
+ req_str = "#{TECHNET}/security/bulletin/services/GetBulletins?"
51
+ req_str << "searchText=#{keyword}&"
52
+ req_str << 'sortField=0&'
53
+ req_str << 'sortOrder=1&'
54
+ req_str << 'currentPage=1&'
55
+ req_str << 'bulletinsPerPage=9999&'
56
+ req_str << 'locale=en-us'
57
+
58
+ res = send_http_get_request(req_str)
59
+ begin
60
+ return JSON.parse(res.body)
61
+ rescue JSON::ParserError
62
+ end
63
+
64
+ {}
65
+ end
66
+
67
+ # Searches for the MSBs (advisories) based on product IDs (as search keywords)
68
+ #
69
+ # @param ids [Array]
70
+ # @return [Array]
71
+ def search_by_product_ids(ids)
72
+ msb_numbers = []
73
+
74
+ ids.each do |id|
75
+ j = search(id)
76
+ msb = j['b'].collect { |e| e['Id'] }.map { |e| e.downcase }
77
+ msb_numbers.concat(msb)
78
+ end
79
+
80
+ msb_numbers
81
+ end
82
+
83
+ # Searches for the MSBs (advisories) based on a keyword.
84
+ #
85
+ # @param keyword [String]
86
+ # @return [Hash]
87
+ def search_by_keyword(keyword)
88
+ j = search(keyword)
89
+ j['b'].collect { |e| e['Id'] }.map { |e| e.downcase }
90
+ end
91
+
92
+ # Returns the Technet product list.
93
+ #
94
+ # @return [Array]
95
+ def get_product_dropdown_list
96
+ @product_dropdown_list ||= lambda {
97
+ list = []
98
+
99
+ page = ::Nokogiri::HTML(firstpage)
100
+ page.search('//div[@class="sb-search"]//select[@id="productDropdown"]//option').each do |product|
101
+ option_value = product.attributes['value'].value
102
+ option_text = product.text
103
+ next if option_value == '-1' # This is the ALL option
104
+ list << { option_value: option_value, option_text: option_text }
105
+ end
106
+
107
+ list
108
+ }.call
109
+ end
110
+
111
+ attr_reader :firstpage
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/msu.rb ADDED
@@ -0,0 +1,234 @@
1
+ require 'nokogiri'
2
+ require 'core/thread_pool'
3
+ require 'engine/msu/google'
4
+ require 'engine/msu/technet'
5
+ require 'engine/msu/constants'
6
+
7
+ module PatchFinder
8
+ class MSU
9
+
10
+ include Helper
11
+
12
+ MAX_THREADS = 10
13
+
14
+ # Returns the download links.
15
+ #
16
+ # @param args [Hash] Arguments created by the user.
17
+ # @return [Array]
18
+ def find_msu_download_links(args)
19
+ msb_numbers = collect_msbs(args)
20
+
21
+ unless msb_numbers.empty?
22
+ print_status("Advisories found (#{msb_numbers.length}): #{msb_numbers * ', '}")
23
+ print_status('Please wait while the download links are being collected...')
24
+ end
25
+
26
+ download_links = []
27
+
28
+ print_status("Max number of active collecting clients: #{MAX_THREADS}")
29
+ pool = PatchFinder::ThreadPool.new(MAX_THREADS)
30
+
31
+ msb_numbers.each do |msb|
32
+ pool.schedule do
33
+ links = collect_links_from_msb(msb, args[:regex])
34
+ pool.mutex.synchronize do
35
+ download_links.concat(links)
36
+ end
37
+ end
38
+ end
39
+
40
+ pool.shutdown
41
+
42
+ sleep(0.5) until pool.eop?
43
+
44
+ download_links
45
+ end
46
+
47
+ private
48
+
49
+ # Returns the MSBs (advisories) numbers.
50
+ #
51
+ # @param args [Hash]
52
+ # @return [Array]
53
+ def collect_msbs(args)
54
+ msb_numbers = []
55
+
56
+ case args[:search_engine]
57
+ when :technet
58
+ print_status("Searching advisories that include #{args[:keyword]} via Technet")
59
+ msb_numbers = technet_search(args[:keyword])
60
+ when :google
61
+ print_status("Searching advisories that include #{args[:keyword]} via Google")
62
+ msb_numbers = google_search(args[:keyword], args[:google_api_key], args[:google_search_engine_id])
63
+ end
64
+
65
+ msb_numbers
66
+ end
67
+
68
+ # Returns the download links for an advisory.
69
+ #
70
+ # @param msb [String]
71
+ # @param regex [Regexp] Search filter
72
+ # @return [Array]
73
+ def collect_links_from_msb(msb, regex = nil)
74
+ unless is_valid_msb?(msb)
75
+ print_error "Not a valid MSB format: #{msb}"
76
+ print_error 'Example of a correct one: ms15-100'
77
+ return []
78
+ end
79
+
80
+ res = download_advisory(msb)
81
+
82
+ if !has_advisory?(res)
83
+ print_error "The advisory cannot be found for #{msb}"
84
+ return []
85
+ end
86
+
87
+ links = get_details_aspx(res)
88
+ if links.length == 0
89
+ print_error "Unable to find download.microsoft.com links for #{msb}."
90
+ return []
91
+ else
92
+ print_status("Found #{links.length} affected products for #{msb}.")
93
+ end
94
+
95
+ link_collector = []
96
+
97
+ links.each do |link|
98
+ download_page = get_download_page(link)
99
+ download_links = get_download_links(download_page.body)
100
+ if regex
101
+ filtered_links = download_links.select { |l| Regexp.new(regex) === l }
102
+ link_collector.concat(filtered_links)
103
+ else
104
+ link_collector.concat(download_links)
105
+ end
106
+ end
107
+
108
+ link_collector
109
+ end
110
+
111
+ # Returns the download page.
112
+ #
113
+ # @param link [string]
114
+ # @return [Net::Response]
115
+ def get_download_page(link)
116
+ res = send_http_get_request(link)
117
+
118
+ if res.header['Location']
119
+ return send_http_get_request("#{PatchFinder::Engine::MSU::MICROSOFT}/#{res.header['Location']}")
120
+ end
121
+
122
+ res
123
+ end
124
+
125
+ # Returns the MSB advisories found from Google.
126
+ #
127
+ # @param keyword [String]
128
+ # @param api_key [String]
129
+ # @param cx [String]
130
+ # @return [Array]
131
+ def google_search(keyword, api_key, cx)
132
+ search = PatchFinder::Engine::MSU::Google.new(api_key: api_key, search_engine_id: cx)
133
+ search.find_msb_numbers(keyword)
134
+ end
135
+
136
+ # Returns the MSB advisories found from Technet.
137
+ #
138
+ # @param keyword [String]
139
+ # @return [Array]
140
+ def technet_search(keyword)
141
+ search = PatchFinder::Engine::MSU::Technet.new
142
+ search.find_msb_numbers(keyword)
143
+ end
144
+
145
+ # Returns the advisory page.
146
+ #
147
+ # @param msb [String]
148
+ # @return [String]
149
+ def download_advisory(msb)
150
+ send_http_get_request("#{PatchFinder::Engine::MSU::TECHNET}/en-us/library/security/#{msb}.aspx")
151
+ end
152
+
153
+ # Returns the found details pages
154
+ #
155
+ # @param res [Res::Response]
156
+ # @return [Array]
157
+ def get_details_aspx(res)
158
+ links = []
159
+
160
+ page = res.body
161
+ n = ::Nokogiri::HTML(page)
162
+
163
+ appropriate_pattern = get_appropriate_pattern(n)
164
+ return links unless appropriate_pattern
165
+
166
+ n.search(appropriate_pattern).each do |anchor|
167
+ found_link = anchor.attributes['href'].value
168
+ if /https:\/\/www\.microsoft\.com\/downloads\/details\.aspx\?familyid=/i === found_link
169
+ begin
170
+ links << found_link
171
+ rescue ::URI::InvalidURIError
172
+ print_error "Unable to parse URI: #{found_link}"
173
+ end
174
+ end
175
+ end
176
+
177
+ links
178
+ end
179
+
180
+ # Returns the downloads links
181
+ #
182
+ # @param page [String] HTML page
183
+ # @return [Array]
184
+ def get_download_links(page)
185
+ page = ::Nokogiri::HTML(page)
186
+
187
+ relative_uri = page.search('a').select { |a|
188
+ a.attributes['href'] && a.attributes['href'].value.include?('confirmation.aspx?id=')
189
+ }.first
190
+
191
+ return [] unless relative_uri
192
+ relative_uri = relative_uri.attributes['href'].value
193
+
194
+ res = send_http_get_request("#{PatchFinder::Engine::MSU::MICROSOFT}/en-us/download/#{relative_uri}")
195
+ n = ::Nokogiri::HTML(res.body)
196
+
197
+ n.search('a').select { |a|
198
+ a.attributes['href'] && a.attributes['href'].value.include?("#{PatchFinder::Engine::MSU::DOWNLOAD_MSFT}/download/")
199
+ }.map! { |a| a.attributes['href'].value }.uniq
200
+ end
201
+
202
+ # Returns a pattern that matches the advisory page.
203
+ #
204
+ # @param n [Nokogiri::HTML::Document]
205
+ # @return [String] If a match is found
206
+ # @return [NilClass] If no match found
207
+ def get_appropriate_pattern(n)
208
+ PatchFinder::Engine::MSU::ADVISORY_PATTERNS.each do |pattern|
209
+ if n.at_xpath(pattern[:check])
210
+ return pattern[:pattern]
211
+ end
212
+ end
213
+
214
+ nil
215
+ end
216
+
217
+ # Checks if the page is an advisory.
218
+ #
219
+ # @param res [Net::Response]
220
+ # @return [Boolean]
221
+ def has_advisory?(res)
222
+ !res.body.include?('We are sorry. The page you requested cannot be found')
223
+ end
224
+
225
+ # Checks if the string is a MSB format.
226
+ #
227
+ # @param msb [String]
228
+ # @return [Boolean]
229
+ def is_valid_msb?(msb)
230
+ /^ms\d\d\-\d\d\d$/i === msb
231
+ end
232
+
233
+ end
234
+ end
@@ -0,0 +1,22 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'patch_finder'
7
+ spec.version = '1.0.1'
8
+ spec.authors = ['wchen-r7']
9
+ spec.email = ['wei_chen@rapid7.com']
10
+ spec.summary = 'Patch Finder'
11
+ spec.description = 'Generic Patch Finder'
12
+ spec.homepage = 'http://github.com/wchen-r7/patch-finder'
13
+ spec.license = 'BSD-3-clause'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.executables = Dir.glob('bin/*').map{ |f| File.basename(f) }
17
+ spec.require_paths = ["lib"]
18
+
19
+ spec.add_development_dependency "bundler", "~> 1.11"
20
+ spec.add_development_dependency "rake", "~> 10.0"
21
+ spec.add_development_dependency "rspec", "~> 3.0"
22
+ end
@@ -0,0 +1,47 @@
1
+ @echo off
2
+ @setlocal EnableDelayedExpansion
3
+
4
+ @set arg=%~1
5
+
6
+ if [%arg%] == [] (
7
+ echo Argument Missing:
8
+ echo You must provide a directory that contains
9
+ echo all the Windows patches in *.msu format.
10
+ echo To Download patches manually, please go:
11
+ echo http://mybulletins.technet.microsoft.com/BulletinPages/Dashboard
12
+ exit /B
13
+ )
14
+
15
+ if not "!arg:~-1,1!" == "\" (
16
+ @set arg=!arg!\
17
+ )
18
+
19
+
20
+ for /f %%f in ('dir /B %arg%') DO (
21
+ @set fname=%%f
22
+ @set lastfourchars=!fname:~-4,4!
23
+ if "!lastfourchars!" == ".msu" (
24
+ rem The -15 length is specific to filenames generated by msu_finder, and filters out the following things:
25
+ rem * The time stamp: "_xxxxxxxxxx"
26
+ rem * The extension name: ".msu"
27
+ rem To make this script generic (no time stamp in the filename), instead of -15, you should do -4.
28
+ @set newname=!fname:~0,-15!
29
+
30
+ rem This is the destination path that contains the time stamp.
31
+ rem The use of time stamp allows the patches to avoid filename collision,
32
+ rem because sometimes even though the patches are different, they might have the
33
+ rem same name.
34
+ @set newdest=!fname:~0,-4!
35
+
36
+ mkdir %arg%!newdest!
37
+ mkdir %arg%!newdest!\extracted
38
+ expand /F:* %arg%!fname! %arg%!newdest!
39
+ expand /F:* %arg%!newdest!\!newname!.cab %arg%!newdest!\extracted
40
+ )
41
+
42
+ )
43
+
44
+ echo Done!
45
+ echo Now go to %arg%,
46
+ echo and then use the search feature from Windows to
47
+ echo find the files you're interested in.
data/tools/list_dll.rb ADDED
@@ -0,0 +1,220 @@
1
+ # -*- coding: binary -*-
2
+ #!/usr/bin/env ruby
3
+
4
+ require 'optparse'
5
+ require 'find'
6
+
7
+ class String
8
+ def to_decimal
9
+ self.unpack("c*").reverse.pack("c*").unpack("N*").first
10
+ end
11
+
12
+ def to_hex
13
+ self.unpack("c*").reverse.pack("c*")
14
+ end
15
+
16
+ def to_printable_hex_32
17
+ self.unpack("c*").reverse.pack("c*").unpack("H*").first
18
+ end
19
+
20
+ def to_printable_hex_64
21
+ self.unpack("s*").pack("s*").reverse.unpack("H*").first
22
+ end
23
+
24
+ def unicode
25
+ buf = ''
26
+
27
+ self.each_char do |c|
28
+ buf << "#{c}\x00"
29
+ end
30
+
31
+ buf
32
+ end
33
+
34
+ def ansi
35
+ buf = ''
36
+
37
+ self.each_char do |c|
38
+ next if c == "\x00"
39
+ buf << c
40
+ end
41
+
42
+ buf
43
+ end
44
+ end
45
+
46
+ class DLLInformation
47
+ X64_86 = :x64_86
48
+ X86 = :x86
49
+
50
+ def initialize(file)
51
+ f = open(file, 'rb')
52
+ begin
53
+ @bin = f.read
54
+ ensure
55
+ f.close if f
56
+ end
57
+ end
58
+
59
+ def cpu_type
60
+ @cpu_type ||= lambda {
61
+ type = @bin.scan(/\x50\x45\x00\x00(..)/).flatten.first || ''
62
+ type == "\x64\x86" ? X64_86 : X86
63
+ }.call
64
+ end
65
+
66
+ def base_address
67
+ case cpu_type
68
+ when X64_86
69
+ base_address_x64_86
70
+ when X86
71
+ base_address_x86
72
+ end
73
+ end
74
+
75
+ def timestamp
76
+ pe_signature_offset = @bin[60,4].to_decimal
77
+ @bin[pe_signature_offset+8, 4].to_printable_hex_32
78
+ end
79
+
80
+ def version_info
81
+ signature_start = "...\x00\x00\x00"
82
+ signature_start << "VS_VERSION_INFO".unicode
83
+ signature_end = "\x00\x00\x00\x00\x09\x04\xb0\x04"
84
+ info = @bin.scan(/(#{signature_start}.+#{signature_end})/).flatten[0]
85
+ return '' if info.nil?
86
+
87
+ signature_start = "FileVersion".unicode
88
+ signature_start << "\x00\x00".unicode
89
+ signature_end = "InternalName".unicode
90
+ file_version = info.scan(/#{signature_start}(.+)\x00\x00.+#{signature_end}/).flatten[0]
91
+ return '' if file_version.nil?
92
+
93
+ file_version
94
+ end
95
+
96
+ def size
97
+ @bin.length
98
+ end
99
+
100
+ private
101
+
102
+ def base_address_x86
103
+ @base_address_x86 ||= lambda {
104
+ pe_signature_offset = @bin[60,4].to_decimal
105
+ coff_header_offset = pe_signature_offset + 4 + 44 + 4
106
+ @bin[coff_header_offset, 4].to_printable_hex_32
107
+ }.call
108
+ end
109
+
110
+ def base_address_x64_86
111
+ @base_address_x64_86 ||= lambda {
112
+ pe_signature_offset = @bin.index("\x50\x45\x00\x00")
113
+ image_base_offset = pe_signature_offset + 48
114
+ @bin[image_base_offset, 8].to_printable_hex_64
115
+ }.call
116
+ end
117
+
118
+ end
119
+
120
+ class Table
121
+ def initialize(opts)
122
+ @path = opts[:path]
123
+ @size = opts[:size]
124
+ @version = opts[:version]
125
+ @base = opts[:base]
126
+ @timestamp = opts[:timestamp]
127
+ end
128
+
129
+ def show
130
+ puts '#{get_size} #{get_base} #{get_timestamp} #{get_version} #{get_path}'
131
+ end
132
+
133
+ private
134
+
135
+ def get_size
136
+ s = [@size].pack("V*").unpack("H*")[0]
137
+ "0x#{s.rjust(8, '0')}"
138
+ end
139
+
140
+ def get_path
141
+ base = "#{Dir.pwd}/"
142
+ p = @path.gsub(/^#{base}/, '')
143
+
144
+ # Max out the length at 44 characters
145
+ p = "#{p[0,44]}..." if p.length > 44
146
+
147
+ p
148
+ end
149
+
150
+ def get_version
151
+ @version.ansi.scan(/^([0-9\.]+) /).flatten.first.ljust(16, ' ')
152
+ end
153
+
154
+ def get_base
155
+ "0x#{@base.ljust(16, ' ')}"
156
+ end
157
+
158
+ def get_timestamp
159
+ "0x#{@timestamp.rjust(8, '0')}"
160
+ end
161
+ end
162
+
163
+ def init_args
164
+ opts = {}
165
+ opt = OptionParser.new
166
+ opt.banner = "Usage: #{__FILE__} [options]"
167
+ opt.separator('')
168
+ opt.separator('Options:')
169
+
170
+ opt.on('-d', '--dll [string]', String, 'DLL to specify') do |n|
171
+ opts[:dll_name] = n
172
+ end
173
+
174
+ opt.on_tail('-h', '--help', 'Show usage') do
175
+ puts opt
176
+ exit(0)
177
+ end
178
+
179
+ opt.parse!
180
+ opts
181
+ end
182
+
183
+ def list_dll(dir, dll_name)
184
+ puts 'Size Base Timestamp Version Path'
185
+
186
+ Find.find(dir) do |p|
187
+ next if p !~ /\.dll$/i
188
+ next if dll_name && p !~ /#{dll_name}/
189
+
190
+ dll = DLLInformation.new(p)
191
+ size = dll.size
192
+ version = dll.version_info
193
+ base_address = dll.base_address
194
+ timestamp = dll.timestamp
195
+
196
+ t = Table.new({
197
+ :path => p,
198
+ :size => size,
199
+ :version => version,
200
+ :base => base_address,
201
+ :timestamp => timestamp
202
+ })
203
+
204
+ t.show
205
+ end
206
+ end
207
+
208
+ def main(args)
209
+ base_dir = Dir.pwd
210
+ list_dll(base_dir, args[:dll_name])
211
+ end
212
+
213
+ args = init_args
214
+ begin
215
+ main(args)
216
+ rescue RuntimeError => e
217
+ puts e.message
218
+ exit(0)
219
+ rescue Interrupt
220
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: patch_finder
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - wchen-r7
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-03-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.11'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.11'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ description: Generic Patch Finder
56
+ email:
57
+ - wei_chen@rapid7.com
58
+ executables:
59
+ - msu_finder
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - Gemfile
64
+ - README.md
65
+ - Rakefile
66
+ - bin/msu_finder
67
+ - docs/bin/msu_finder.txt
68
+ - lib/core/config.rb
69
+ - lib/core/helper.rb
70
+ - lib/core/thread_pool.rb
71
+ - lib/engine/msu/constants.rb
72
+ - lib/engine/msu/google.rb
73
+ - lib/engine/msu/technet.rb
74
+ - lib/msu.rb
75
+ - patch_finder.gemspec
76
+ - tools/extract_msu.bat
77
+ - tools/list_dll.rb
78
+ homepage: http://github.com/wchen-r7/patch-finder
79
+ licenses:
80
+ - BSD-3-clause
81
+ metadata: {}
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubyforge_project:
98
+ rubygems_version: 2.4.8
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: Patch Finder
102
+ test_files: []