dobby 0.1.0 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +16 -0
- data/.rubocop.yml +30 -0
- data/.rubocop_todo.yml +42 -0
- data/.travis.yml +12 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +8 -0
- data/CONTRIBUTING.md +60 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +103 -0
- data/Rakefile +8 -0
- data/bin/console +7 -0
- data/bin/setup +8 -0
- data/config/default.yml +8 -0
- data/dobby.gemspec +58 -0
- data/lib/dobby.rb +51 -0
- data/lib/dobby/builtins.rb +17 -0
- data/lib/dobby/cli.rb +64 -0
- data/lib/dobby/configuration.rb +58 -0
- data/lib/dobby/database.rb +62 -0
- data/lib/dobby/defect.rb +74 -0
- data/lib/dobby/dpkg.rb +21 -0
- data/lib/dobby/error.rb +6 -0
- data/lib/dobby/flag_manager.rb +67 -0
- data/lib/dobby/flags.yml +8 -0
- data/lib/dobby/formatter/abstract_formatter.rb +25 -0
- data/lib/dobby/formatter/colorizable.rb +41 -0
- data/lib/dobby/formatter/formatter_set.rb +79 -0
- data/lib/dobby/formatter/json_formatter.rb +42 -0
- data/lib/dobby/formatter/simple_formatter.rb +54 -0
- data/lib/dobby/options.rb +149 -0
- data/lib/dobby/package.rb +156 -0
- data/lib/dobby/package_source/abstract_package_source.rb +17 -0
- data/lib/dobby/package_source/dpkg_status_file.rb +85 -0
- data/lib/dobby/runner.rb +152 -0
- data/lib/dobby/scanner.rb +128 -0
- data/lib/dobby/severity.rb +66 -0
- data/lib/dobby/strategy.rb +168 -0
- data/lib/dobby/update_response.rb +19 -0
- data/lib/dobby/version.rb +24 -0
- data/lib/dobby/vuln_source/abstract_vuln_source.rb +26 -0
- data/lib/dobby/vuln_source/debian.rb +166 -0
- data/lib/dobby/vuln_source/ubuntu.rb +229 -0
- 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
|
data/lib/dobby/runner.rb
ADDED
@@ -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
|