dobby 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +16 -0
  3. data/.rubocop.yml +30 -0
  4. data/.rubocop_todo.yml +42 -0
  5. data/.travis.yml +12 -0
  6. data/.yardopts +2 -0
  7. data/CHANGELOG.md +8 -0
  8. data/CONTRIBUTING.md +60 -0
  9. data/Gemfile +8 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +103 -0
  12. data/Rakefile +8 -0
  13. data/bin/console +7 -0
  14. data/bin/setup +8 -0
  15. data/config/default.yml +8 -0
  16. data/dobby.gemspec +58 -0
  17. data/lib/dobby.rb +51 -0
  18. data/lib/dobby/builtins.rb +17 -0
  19. data/lib/dobby/cli.rb +64 -0
  20. data/lib/dobby/configuration.rb +58 -0
  21. data/lib/dobby/database.rb +62 -0
  22. data/lib/dobby/defect.rb +74 -0
  23. data/lib/dobby/dpkg.rb +21 -0
  24. data/lib/dobby/error.rb +6 -0
  25. data/lib/dobby/flag_manager.rb +67 -0
  26. data/lib/dobby/flags.yml +8 -0
  27. data/lib/dobby/formatter/abstract_formatter.rb +25 -0
  28. data/lib/dobby/formatter/colorizable.rb +41 -0
  29. data/lib/dobby/formatter/formatter_set.rb +79 -0
  30. data/lib/dobby/formatter/json_formatter.rb +42 -0
  31. data/lib/dobby/formatter/simple_formatter.rb +54 -0
  32. data/lib/dobby/options.rb +149 -0
  33. data/lib/dobby/package.rb +156 -0
  34. data/lib/dobby/package_source/abstract_package_source.rb +17 -0
  35. data/lib/dobby/package_source/dpkg_status_file.rb +85 -0
  36. data/lib/dobby/runner.rb +152 -0
  37. data/lib/dobby/scanner.rb +128 -0
  38. data/lib/dobby/severity.rb +66 -0
  39. data/lib/dobby/strategy.rb +168 -0
  40. data/lib/dobby/update_response.rb +19 -0
  41. data/lib/dobby/version.rb +24 -0
  42. data/lib/dobby/vuln_source/abstract_vuln_source.rb +26 -0
  43. data/lib/dobby/vuln_source/debian.rb +166 -0
  44. data/lib/dobby/vuln_source/ubuntu.rb +229 -0
  45. metadata +45 -1
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dobby
4
+ module PackageSource
5
+ # @abstract Subclass and override {#parse} to implement a custom Package
6
+ # source.
7
+ class AbstractPackageSource
8
+ include Dobby::Strategy
9
+
10
+ # All logic for creating an Array<Package>]
11
+ # @return [Array<Package>]
12
+ def parse
13
+ raise NotImplementedError
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dobby
4
+ module PackageSource
5
+ # Defines a strategy for creating a {Package} array from /var/lib/dpkg/status
6
+ # or a similarly formatted file.
7
+ class DpkgStatusFile < AbstractPackageSource
8
+ args %i[file_path dist release]
9
+
10
+ option :file_path, '/var/lib/dpkg/status'
11
+ option :dist, 'Debian'
12
+ option :release, Dpkg.code_name
13
+
14
+ # A Dpkg section has unexpected formatting
15
+ class DpkgFormatError < Error; end
16
+
17
+ # rubocop:disable Layout/AlignArray
18
+ def self.cli_options
19
+ [
20
+ ['--release NAME', 'Release code name for package definitions.',
21
+ 'Defaults to the code name of the current system.'],
22
+ ['--dist DIST', 'The full name of the distribution for package definitions.',
23
+ 'Defaults to "Debian".']
24
+ ]
25
+ end
26
+ # rubocop:enable Layout/AlignArray
27
+
28
+ # @return [Array<Package>]
29
+ def parse
30
+ packages = []
31
+ File.read(options.file_path).split("\n\n").each do |section|
32
+ begin
33
+ packages << package_from_section(section)
34
+ rescue Package::FieldRequiredError => e
35
+ # If the Version field is missing, this is probably a virtual
36
+ # or meta-package (e.g. little-table-dev) - Skip it. Name and
37
+ # release should never be missing, so reraise the error in those
38
+ # cases.
39
+ next if e.field == 'version'
40
+
41
+ raise
42
+ end
43
+ end
44
+ packages
45
+ end
46
+
47
+ private
48
+
49
+ # @param section [String]
50
+ # @return [Package]
51
+ # @raise [FormatError]
52
+ def package_from_section(section)
53
+ pkg = {}
54
+ field = nil
55
+
56
+ section.each_line do |line|
57
+ line.chomp!
58
+ if /^\s/ =~ line
59
+ raise FormatError, "Unexpected whitespace at start of line: '#{line}'" unless field
60
+
61
+ pkg[field] += "\n" + line
62
+ elsif /(^\S+):\s*(.*)/ =~ line
63
+ field = Regexp.last_match(1).capitalize
64
+ if pkg.key?(field)
65
+ raise FormatError, "Unexpected duplicate field '#{field}' in line '#{line}'"
66
+ end
67
+
68
+ value = Regexp.last_match(2).strip
69
+ value = value.split[0] if field == 'Source'
70
+ pkg[field] = value
71
+ end
72
+ end
73
+ Package.new(
74
+ name: pkg['Package'],
75
+ version: pkg['Version'],
76
+ dist: options.dist,
77
+ release: options.release,
78
+ arch: pkg['Architecture'],
79
+ source: pkg['Source'],
80
+ multiarch: pkg['Multi-Arch']
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dobby
4
+ class Runner
5
+ attr_reader :errors, :aborting
6
+ alias aborting? aborting
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ @errors = []
11
+ @aborting = false
12
+ end
13
+
14
+ def run(paths)
15
+ target_files = resolve_paths(paths)
16
+ return list_files(target_files) if @options[:list_target_files]
17
+
18
+ load_vuln_source(@options[:vuln_source])
19
+ load_package_source(@options[:package_source])
20
+ load_scanner
21
+ inspect_files(target_files)
22
+ end
23
+
24
+ private
25
+
26
+ def inspect_files(files)
27
+ inspected = []
28
+
29
+ formatter_set.started(files)
30
+
31
+ each_inspected_file(files) { |file| inspected << file }
32
+ ensure
33
+ formatter_set.finished(inspected.freeze)
34
+ formatter_set.close_output_files
35
+ end
36
+
37
+ def resolve_paths(target_files)
38
+ target_files.map { |f| File.expand_path(f) }.freeze
39
+ end
40
+
41
+ def each_inspected_file(files)
42
+ files.reduce(true) do |passed, file|
43
+ break false if aborting?
44
+
45
+ results = process_file(file)
46
+
47
+ yield file
48
+
49
+ if results.any?
50
+ break false if @options[:fail_fast]
51
+
52
+ next false
53
+ end
54
+
55
+ passed
56
+ end
57
+ end
58
+
59
+ def formatter_set
60
+ @formatter_set ||= begin
61
+ set = Formatter::FormatterSet.new(@options)
62
+ pairs = @options[:formatters] || [['simple', @options[:output_path]]]
63
+ pairs.each do |formatter_key, output_path|
64
+ set.add_formatter(formatter_key, output_path)
65
+ end
66
+ set
67
+ end
68
+ end
69
+
70
+ def load_package_source(selected)
71
+ selected ||= 'dpkg'
72
+ @package_source = package_source_loader(selected)
73
+ end
74
+
75
+ def load_vuln_source(selected)
76
+ selected ||= 'debian'
77
+ @vuln_source = vuln_source_loader(selected)
78
+ end
79
+
80
+ def load_scanner
81
+ vuln_source = @vuln_source.new(@options)
82
+ @database = Dobby::Database.new(vuln_source)
83
+ @scanner = Scanner.new(nil, @database)
84
+ end
85
+
86
+ def process_file(file = nil)
87
+ formatter_set.file_started(file)
88
+ source = @package_source.new(@options.merge(file_path: file))
89
+ packages = source.parse
90
+ results = run_scanner(packages)
91
+ formatter_set.file_finished(file, results)
92
+ results
93
+ end
94
+
95
+ def run_scanner(packages)
96
+ @scanner.packages = packages
97
+ @scanner.scan(defect_filter: Scanner::DEFECT_FILTER_FIXED)
98
+ end
99
+
100
+ def fuzzy_source_name(key, sources)
101
+ sources.keys.select { |k| k.start_with?(key) }
102
+ end
103
+
104
+ def builtin_package_source_class(specified)
105
+ matching = fuzzy_source_name(specified, Builtins::PACKAGE_SOURCES)
106
+
107
+ raise %(No package source for "#{specified}") if matching.empty?
108
+ raise %(Ambiguous package source for "#{specified}") if matching.size > 1
109
+
110
+ Builtins::PACKAGE_SOURCES[matching.first]
111
+ end
112
+
113
+ def builtin_vuln_source_class(specified)
114
+ matching = fuzzy_source_name(specified, Builtins::VULN_SOURCES)
115
+
116
+ raise %(No vulnerability source for "#{specified}") if matching.empty?
117
+ raise %(Ambiguous vulnerability source for "#{specified}") if matching.size > 1
118
+
119
+ Builtins::VULN_SOURCES[matching.first]
120
+ end
121
+
122
+ def vuln_source_loader(specifier)
123
+ case specifier
124
+ when Class
125
+ specifier
126
+ when /\A[A-Z]/
127
+ custom_class(specifier)
128
+ else
129
+ builtin_vuln_source_class(specifier)
130
+ end
131
+ end
132
+
133
+ def package_source_loader(specifier)
134
+ case specifier
135
+ when Class
136
+ specifier
137
+ when /\A[A-Z]/
138
+ custom_class(specifier)
139
+ else
140
+ builtin_package_source_class(specifier)
141
+ end
142
+ end
143
+
144
+ def custom_class(name)
145
+ constant_names = name.split('::')
146
+ constant_names.shift if constant_names.first.empty?
147
+ constant_names.reduce(Object) do |namespace, constant_name|
148
+ namespace.const_get(constant_name, false)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dobby
4
+ # Compares a {Database} and {Array<Package>} to discover what defects
5
+ # affect a system.
6
+ class Scanner
7
+ attr_reader :database, :packages, :results
8
+
9
+ FLAG_FILTER_ALL = :all
10
+ FLAG_FILTER_ALLOWED = :allowed
11
+ FLAG_FILTER_WHITELISTED = :whitelisted
12
+ FLAG_FILTER_DEFAULT = :default
13
+
14
+ DEFECT_FILTER_DEFAULT = :default
15
+ DEFECT_FILTER_FIXED = :only_fixed
16
+
17
+ VERSION_FILTER_TARGET = :target
18
+ VERSION_FILTER_DEFAULT = :default
19
+
20
+ # @param packages [Array<Package>]
21
+ # @param database [Database]
22
+ def initialize(packages, database)
23
+ @packages = packages
24
+ @database = database
25
+
26
+ @results = Hash.new { |h, k| h[k] = [] }
27
+ end
28
+
29
+ # Whenever the package set the scanner is configured with change,
30
+ # the stored results will be wiped.
31
+ def packages=(arr)
32
+ @results.clear
33
+ @packages = arr
34
+ end
35
+
36
+ # Determine which packages are affected by which defects.
37
+ #
38
+ # @option [Symbol] :defect_filter {DEFECT_FILTER_DEFAULT}
39
+ # - {DEFECT_FILTER_DEFAULT}
40
+ # Apply no special filters to defects in the database
41
+ # - {DEFECT_FILTER_FIXED}
42
+ # Only include defects that have a fix available
43
+ # @option [Symbol] :flag_filter {FLAG_FILTER_DEFAULT}
44
+ # - {FLAG_FILTER_ALL}
45
+ # Apply no filter at all to the results
46
+ # - {FLAG_FILTER_DEFAULT}
47
+ # Ignore results marked with any flag
48
+ # - {FLAG_FILTER_ALLOWED}
49
+ # Include only results flagged allowed
50
+ # - {FLAG_FILTER_WHITELISTED}
51
+ # Include only results flagged whitelisted
52
+ # @option [Symbol] :version_filter {VERSION_FILTER_DEFAULT}
53
+ # - {VERSION_FILTER_DEFAULT}
54
+ # For a given package and defect, ensure that the release of the defect's
55
+ # fix versions match the package and that the version of the package
56
+ # is less than the fix version
57
+ # - {VERSION_FILTER_TARGET}
58
+ # See {#scan_by_target} for more information on how this complex filter behaves.
59
+ #
60
+ # Order of filter processing, from first to last:
61
+ # 1. Flag
62
+ # 2. Defect
63
+ # 3. Version
64
+ def scan(defect_filter: DEFECT_FILTER_DEFAULT,
65
+ flag_filter: FLAG_FILTER_DEFAULT,
66
+ version_filter: VERSION_FILTER_DEFAULT)
67
+ @results.clear
68
+ packages.each do |package|
69
+ scan_results = scan_one(package: package,
70
+ defect_filter: defect_filter,
71
+ flag_filter: flag_filter,
72
+ version_filter: version_filter)
73
+ @results.merge! scan_results
74
+ end
75
+ results
76
+ end
77
+
78
+ # For a given package, determine which packages affect it, if any.
79
+ def scan_one(package:,
80
+ defect_filter: DEFECT_FILTER_DEFAULT,
81
+ flag_filter: FLAG_FILTER_DEFAULT,
82
+ version_filter: VERSION_FILTER_DEFAULT)
83
+ res = Hash.new { |h, k| h[k] = [] }
84
+ database.defects_for(package).each do |defect|
85
+ next if defect.filtered?(filter: defect_filter, flag_filter: flag_filter)
86
+
87
+ defect.fixed_in.each do |v|
88
+ next if package.filtered?(v, version_filter)
89
+
90
+ res[package] << defect
91
+ end
92
+ end
93
+ res
94
+ end
95
+
96
+ # Determine which defects are resolved by upgrading to a target version.
97
+ #
98
+ # Given:
99
+ # - A Package at version 1 and a target version of 3
100
+ # - A Defect, D1, with a fix version of 2
101
+ # - A Defect, D2, with a fix version of 3
102
+ # - A Defect, D3, with a fix version of 4
103
+ #
104
+ # Returns D1 and D2, but not D3.
105
+ #
106
+ # This is a use-specific wrapper around {#scan}
107
+ #
108
+ # @note Packages that do not have a target version set are skipped.
109
+ def scan_by_target
110
+ scan(defect_filter: DEFECT_FILTER_FIXED,
111
+ flag_filter: FLAG_FILTER_DEFAULT,
112
+ version_filter: VERSION_FILTER_TARGET)
113
+ end
114
+
115
+ # For a specific package, determine which defects are resolved by upgrading
116
+ # to its' target version.
117
+ #
118
+ # This is a use-specific wrapper around {scan_one}
119
+ #
120
+ # @param [Package]
121
+ def scan_one_by_target(package)
122
+ scan_one(package: package,
123
+ defect_filter: DEFECT_FILTER_FIXED,
124
+ flag_filter: FLAG_FILTER_DEFAULT,
125
+ version_filter: VERSION_FILTER_TARGET)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dobby
4
+ # Standardized definitions for severity categories
5
+ module Severity
6
+ # Implements a custom <=> method so that severities can be sorted as
7
+ # we see fit. In particular, these objects sort based on their index
8
+ # in the {SEVERITIES} array.
9
+ class Severity
10
+ attr_reader :value
11
+ def initialize(value)
12
+ @value = value
13
+ end
14
+
15
+ def to_s
16
+ @value
17
+ end
18
+
19
+ def ==(other)
20
+ value == other.value
21
+ end
22
+
23
+ def <=>(other)
24
+ return 0 if other == self
25
+ return 1 if SEVERITIES.index(self) < SEVERITIES.index(other)
26
+
27
+ -1
28
+ end
29
+
30
+ def <(other)
31
+ SEVERITIES.index(self) < SEVERITIES.index(other)
32
+ end
33
+ end
34
+
35
+ # A defect which has not yet been assigned a priority or we do not have
36
+ # a translation for.
37
+ Unknown = Severity.new('Unknown')
38
+
39
+ # Technically a security issue, but has no real damage, extremely strict
40
+ # requirements, or other constraints that nullify impact.
41
+ Negligible = Severity.new('Negligible')
42
+
43
+ # Security problem, but difficult to exploit, requires user assistance
44
+ # or does very little damage.
45
+ Low = Severity.new('Low')
46
+
47
+ # "Real" security problem that is generally exploitable.
48
+ Medium = Severity.new('Medium')
49
+
50
+ # Real "problem", that is generally exploitable in a default configuration.
51
+ High = Severity.new('High')
52
+
53
+ # The world is on fire, send help!
54
+ Critical = Severity.new('Critical')
55
+
56
+ # All severities in an ordered list
57
+ SEVERITIES = [
58
+ Unknown,
59
+ Negligible,
60
+ Low,
61
+ Medium,
62
+ High,
63
+ Critical
64
+ ].freeze
65
+ end
66
+ end