patch_finder 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []