sastbox_sdk 1.0.0
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 +7 -0
- data/README.md +0 -0
- data/lib/sastbox-sdk.rb +26 -0
- data/lib/sastbox-sdk/codebase.rb +9 -0
- data/lib/sastbox-sdk/cwe_constants.rb +87 -0
- data/lib/sastbox-sdk/cwe_detector.rb +202 -0
- data/lib/sastbox-sdk/opt_parser.rb +45 -0
- data/lib/sastbox-sdk/printer.rb +86 -0
- data/lib/sastbox-sdk/reporter_sarif.rb +112 -0
- data/lib/sastbox-sdk/runner.rb +55 -0
- data/lib/sastbox-sdk/scanner.rb +152 -0
- data/lib/sastbox-sdk/severity_calculator.rb +82 -0
- data/lib/sastbox-sdk/snippet.rb +107 -0
- data/spec/samples/low.php +21 -0
- data/spec/samples/sarif-2.1.0-rtm.5.json +3370 -0
- data/spec/sastbox-sdk/codebase_spec.rb +7 -0
- data/spec/sastbox-sdk/cwe_constants_spec.rb +7 -0
- data/spec/sastbox-sdk/cwe_detector_spec.rb +216 -0
- data/spec/sastbox-sdk/opt_parser_spec.rb +47 -0
- data/spec/sastbox-sdk/printer_spec.rb +59 -0
- data/spec/sastbox-sdk/reporter_sarif_spec.rb +57 -0
- data/spec/sastbox-sdk/runner_spec.rb +92 -0
- data/spec/sastbox-sdk/scanner_spec.rb +238 -0
- data/spec/sastbox-sdk/severity_calculator_spec.rb +126 -0
- data/spec/sastbox-sdk/snippet_spec.rb +175 -0
- data/spec/sastbox-sdk_spec.rb +8 -0
- data/spec/spec_helper.rb +109 -0
- metadata +96 -0
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
module SastBox
|
4
|
+
module Reporter
|
5
|
+
module Sarif
|
6
|
+
|
7
|
+
def generate_sarif_report
|
8
|
+
new_sarif_log
|
9
|
+
@issues.each do |issue|
|
10
|
+
sarif_result = convert_to_sarif_result(issue)
|
11
|
+
@sarif_results << sarif_result unless sarif_result.nil?
|
12
|
+
end
|
13
|
+
|
14
|
+
begin
|
15
|
+
JSON.pretty_generate(@sarif_log)
|
16
|
+
rescue JSON::GeneratorError => e
|
17
|
+
print_error("Could not generate sarif result=> #{e}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def new_sarif_log
|
22
|
+
@sarif_results = []
|
23
|
+
@sarif_rules = []
|
24
|
+
@sarif_log = {
|
25
|
+
'version': '2.1.0',
|
26
|
+
'$schema': 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
|
27
|
+
'runs': sarif_runs
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def sarif_runs
|
32
|
+
[{
|
33
|
+
'tool': { 'driver': {'name': @name, 'informationUri': '', 'semanticVersion': @version, 'rules': @sarif_rules } },
|
34
|
+
'results': @sarif_results
|
35
|
+
}]
|
36
|
+
end
|
37
|
+
|
38
|
+
def make_rule(issue)
|
39
|
+
rule_id = "#{@name}-#{Digest::SHA1.hexdigest(issue[:title])}"
|
40
|
+
rule_name = issue[:title]
|
41
|
+
help_uri = ''
|
42
|
+
help_uri = issue[:references].first if issue[:references].length > 0
|
43
|
+
|
44
|
+
rule_index = @sarif_rules.index { |r| r[:id] == rule_id }
|
45
|
+
if rule_index.nil?
|
46
|
+
rule = {
|
47
|
+
id: rule_id,
|
48
|
+
name: rule_name,
|
49
|
+
shortDescription: {text: issue[:title]},
|
50
|
+
fullDescription: {text: issue[:title]},
|
51
|
+
helpUri: help_uri,
|
52
|
+
help: {text: ''}
|
53
|
+
}
|
54
|
+
rule_index = @sarif_rules.length
|
55
|
+
@sarif_rules << rule
|
56
|
+
|
57
|
+
end
|
58
|
+
rule_index
|
59
|
+
end
|
60
|
+
|
61
|
+
def convert_to_sarif_result(issue)
|
62
|
+
rule_index = make_rule(issue)
|
63
|
+
rule = @sarif_rules[rule_index]
|
64
|
+
|
65
|
+
relative_path = filename_relative(issue[:filename])
|
66
|
+
return nil if relative_path.nil?
|
67
|
+
|
68
|
+
snippet = issue[:snippet]
|
69
|
+
|
70
|
+
sarif_result = {
|
71
|
+
ruleId: rule[:id],
|
72
|
+
ruleIndex: rule_index,
|
73
|
+
level: 'warning',
|
74
|
+
message: {text: issue[:description]},
|
75
|
+
|
76
|
+
locations: [{
|
77
|
+
physicalLocation: {
|
78
|
+
artifactLocation: {uri: relative_path, uriBaseId: '%SRCROOT%'},
|
79
|
+
region: {
|
80
|
+
snippet: {
|
81
|
+
text: snippet[:evidence_line][:content]
|
82
|
+
},
|
83
|
+
startLine: snippet[:evidence_line][:start_line]
|
84
|
+
},
|
85
|
+
contextRegion: {
|
86
|
+
snippet: {
|
87
|
+
text: snippet[:evidence_full][:content]
|
88
|
+
},
|
89
|
+
startLine: snippet[:evidence_full][:start_line],
|
90
|
+
endLine: snippet[:evidence_full][:end_line]
|
91
|
+
}
|
92
|
+
}
|
93
|
+
}],
|
94
|
+
partialFingerprints: {
|
95
|
+
hashIssueV1: issue[:hash_issue], # compatible with sastbox v1
|
96
|
+
hashIssueV2: issue[:hash_issue_v2],
|
97
|
+
snippetHashLine: snippet[:evidence_line][:hash],
|
98
|
+
snippetHashFull: snippet[:evidence_full][:hash]
|
99
|
+
},
|
100
|
+
properties: {
|
101
|
+
cweId: issue[:cwe_id].to_i,
|
102
|
+
tags: issue[:tags],
|
103
|
+
issueSeverity: issue[:severity],
|
104
|
+
}
|
105
|
+
}
|
106
|
+
sarif_result
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
|
3
|
+
module SastBox
|
4
|
+
module Runner
|
5
|
+
|
6
|
+
# TODO: find a better way to do this
|
7
|
+
def command?(name)
|
8
|
+
`which #{name}`
|
9
|
+
$?.success?
|
10
|
+
end
|
11
|
+
|
12
|
+
def run_cmd(cmd)
|
13
|
+
print_debug(cmd) if @opts.verbose
|
14
|
+
if command?(cmd[0])
|
15
|
+
run_cmd_with_timeout(cmd)
|
16
|
+
else
|
17
|
+
print_error("Command not found: #{cmd[0]}")
|
18
|
+
exit 1
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def run_cmd_with_timeout(cmd)
|
23
|
+
out_reader = ''
|
24
|
+
err_reader = ''
|
25
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
26
|
+
# https://stackoverflow.com/questions/8952043/how-to-fix-hanging-popen3-in-ruby
|
27
|
+
stdin.close_write
|
28
|
+
output, pid = [], wait_thr.pid
|
29
|
+
begin
|
30
|
+
Timeout.timeout(@opts.timeout) do
|
31
|
+
begin
|
32
|
+
err_reader = Thread.new { stderr.read }
|
33
|
+
rescue IOError
|
34
|
+
end
|
35
|
+
|
36
|
+
out_reader = stdout.read
|
37
|
+
|
38
|
+
#output = [stdout.read, stderr.read]
|
39
|
+
Process.wait(pid)
|
40
|
+
end
|
41
|
+
rescue Errno::ECHILD
|
42
|
+
rescue Timeout::Error
|
43
|
+
print_error('Timed out - Skipping...')
|
44
|
+
Process.kill('HUP', pid)
|
45
|
+
exit 1
|
46
|
+
end
|
47
|
+
[out_reader, wait_thr.value]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
|
55
|
+
|
@@ -0,0 +1,152 @@
|
|
1
|
+
module SastBox
|
2
|
+
class Scanner
|
3
|
+
|
4
|
+
include SastBox::OptParser
|
5
|
+
include SastBox::Printer
|
6
|
+
include SastBox::Runner
|
7
|
+
include SastBox::Snippet
|
8
|
+
include SastBox::Reporter::Sarif
|
9
|
+
include SastBox::Cwe
|
10
|
+
include SastBox::SeverityCalculator
|
11
|
+
|
12
|
+
attr_accessor :issues
|
13
|
+
|
14
|
+
def initialize(params)
|
15
|
+
@name = params[:name]
|
16
|
+
@name_alias = params[:name_alias] || @name
|
17
|
+
@description = params[:description]
|
18
|
+
@support = params[:support]
|
19
|
+
@version = params[:version]
|
20
|
+
@tool = params[:tool]
|
21
|
+
|
22
|
+
@issues = []
|
23
|
+
end
|
24
|
+
|
25
|
+
def info
|
26
|
+
{
|
27
|
+
name: @name,
|
28
|
+
description: @description,
|
29
|
+
support: @support,
|
30
|
+
version: @version,
|
31
|
+
sdk_version: SastBox::SDK_VERSION
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
def add_hash_issue_v1(issue) # compatibility with sastbox v1
|
36
|
+
issue[:hash_issue] = ''
|
37
|
+
return if got_line_range?(issue) # hash v1 not needed for scanners which report start/end line
|
38
|
+
|
39
|
+
scanner_name = @name_alias
|
40
|
+
short_filename = issue[:filename].sub(@opts.codebase, '')
|
41
|
+
data = [
|
42
|
+
scanner_name,
|
43
|
+
issue[:title],
|
44
|
+
issue[:description],
|
45
|
+
short_filename,
|
46
|
+
issue[:line].to_s,
|
47
|
+
(issue[:snippet][:evidence_line][:hash] || ''),
|
48
|
+
]
|
49
|
+
issue[:hash_issue] = Digest::SHA256.hexdigest(data.join(':'))
|
50
|
+
end
|
51
|
+
|
52
|
+
def add_hash_issue_v2(issue)
|
53
|
+
filename_path = filename_relative(issue[:filename])
|
54
|
+
filename_path = '' if filename_path.nil?
|
55
|
+
data = [
|
56
|
+
issue[:title],
|
57
|
+
issue[:description],
|
58
|
+
filename_path,
|
59
|
+
got_line_range?(issue) ? "#{issue[:start_line].to_s}-#{issue[:end_line].to_s}" : issue[:line].to_s,
|
60
|
+
(issue[:snippet][:evidence_line][:hash]|| ''),
|
61
|
+
]
|
62
|
+
issue[:hash_issue_v2] = Digest::SHA256.hexdigest(data.join(':'))
|
63
|
+
end
|
64
|
+
|
65
|
+
def got_line_range?(issue)
|
66
|
+
issue.key?(:start_line) && issue.key?(:end_line)
|
67
|
+
end
|
68
|
+
|
69
|
+
def add_issue(issue)
|
70
|
+
return if skip_issue?(issue)
|
71
|
+
issue[:tags] = [] unless issue.key? :tags
|
72
|
+
add_severity(issue)
|
73
|
+
add_hash_issue_v1(issue)
|
74
|
+
add_hash_issue_v2(issue)
|
75
|
+
guess_cwe(issue)
|
76
|
+
@issues << issue
|
77
|
+
end
|
78
|
+
|
79
|
+
def skip_issue?(issue)
|
80
|
+
return true if issue[:filename].include?('/.git/')
|
81
|
+
return true if issue[:snippet][:read_success] == false
|
82
|
+
return false # valid issue
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate_opts
|
86
|
+
enable_color(@opts.color)
|
87
|
+
if @opts.info
|
88
|
+
puts JSON.pretty_generate(info)
|
89
|
+
exit 0
|
90
|
+
end
|
91
|
+
|
92
|
+
if @opts.output.nil?
|
93
|
+
print_error('output (-o) not passed')
|
94
|
+
exit 0
|
95
|
+
end
|
96
|
+
|
97
|
+
if @opts.codebase.nil?
|
98
|
+
print_error('codebase (-c) not passed')
|
99
|
+
exit 0
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def start_scan
|
104
|
+
validate_opts
|
105
|
+
|
106
|
+
print_title("Running #{@name}")
|
107
|
+
run
|
108
|
+
#finish_scan
|
109
|
+
end
|
110
|
+
|
111
|
+
def finish_scan
|
112
|
+
status = 0
|
113
|
+
status = 1 unless @issues.empty?
|
114
|
+
print_title("Finished #{@name}")
|
115
|
+
exit status
|
116
|
+
end
|
117
|
+
|
118
|
+
def save_scan_output
|
119
|
+
File.open(@opts.output, "wb") { |file| file.write(generate_sarif_report) }
|
120
|
+
print_normal("Sarif result saved to #{@opts.output}", 1)
|
121
|
+
end
|
122
|
+
|
123
|
+
def gen_random_tmp_filename(suffix = '')
|
124
|
+
File.join(Dir.tmpdir, "#{SecureRandom.urlsafe_base64}#{suffix}")
|
125
|
+
end
|
126
|
+
|
127
|
+
def gen_random_tmp_filename_json
|
128
|
+
gen_random_tmp_filename('.json')
|
129
|
+
end
|
130
|
+
|
131
|
+
def parse_json_from_str(s)
|
132
|
+
content = nil
|
133
|
+
unless s.nil?
|
134
|
+
begin
|
135
|
+
content = JSON.parse(s)
|
136
|
+
rescue JSON::ParserError
|
137
|
+
end
|
138
|
+
end
|
139
|
+
content
|
140
|
+
end
|
141
|
+
|
142
|
+
def parse_json_from_file(filename)
|
143
|
+
content = nil
|
144
|
+
if File.exist?(filename)
|
145
|
+
content = parse_json_from_str(File.read(filename))
|
146
|
+
end
|
147
|
+
content
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module SastBox
|
2
|
+
module SeverityCalculator
|
3
|
+
|
4
|
+
def add_severity(issue)
|
5
|
+
accepted_levels = [:info, :low, :medium, :high, :critical]
|
6
|
+
|
7
|
+
if issue.key?(:severity)
|
8
|
+
issue[:severity] = issue[:severity].to_s.downcase.to_sym
|
9
|
+
|
10
|
+
unless accepted_levels.include?(issue[:severity])
|
11
|
+
issue[:severity] = attempt_to_determine_severity(issue)
|
12
|
+
end
|
13
|
+
else
|
14
|
+
issue[:severity] = attempt_to_determine_severity(issue)
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
def severity_pattern_found?(patterns, text)
|
20
|
+
patterns.each do |pattern|
|
21
|
+
return true if text.include?(pattern)
|
22
|
+
end
|
23
|
+
false
|
24
|
+
end
|
25
|
+
|
26
|
+
def attempt_to_determine_severity(issue)
|
27
|
+
text = "#{issue[:title]} #{issue[:description]}".downcase.gsub(/[^a-z ]/, ' ')
|
28
|
+
|
29
|
+
level = :undefined
|
30
|
+
|
31
|
+
critical = ['command exec', 'cmd exec', 'command inj', 'cmd inj', 'code inj', 'code exec',
|
32
|
+
'file inclusion', 'dangerous send', 'insecure deserialization']
|
33
|
+
|
34
|
+
high = ['xss', 'cross site scripting', 'sqli', 'sql injection', 'insecure url',
|
35
|
+
'file manipulation', 'file access', 'file disclosure', 'idor', 'xpath inj',
|
36
|
+
'weak cipher', 'weak crypto', 'insecure cipher', 'insecure crypto', 'insecure encryption',
|
37
|
+
'broken cipher', 'broken crypto', 'weak hash', 'insecure hash', 'broken hash',
|
38
|
+
'pathtraversal', 'path traversal', 'xxe', 'xml external entities']
|
39
|
+
|
40
|
+
medium = ['mass assignment', 'secret keyword',
|
41
|
+
'hard coded pass', 'hardcoded pass', 'pass hardcoded', 'password hardcoded',
|
42
|
+
'password hard coded', 'secret hardcoded',
|
43
|
+
'session fixation', 'security misconfiguration', 'vulnerable component',
|
44
|
+
'header injection', 'csrf', 'cross site request forgery',
|
45
|
+
'toctou', 'session setting', 'response splitting'
|
46
|
+
]
|
47
|
+
|
48
|
+
low = ['open redirect', 'resource leak', 'format validation',
|
49
|
+
'information disclosure', 'logging', 'logger', 'stacktrace', 'stack trace',
|
50
|
+
'null pointer deref']
|
51
|
+
|
52
|
+
info = []
|
53
|
+
|
54
|
+
|
55
|
+
if level == :undefined
|
56
|
+
level = :critical if severity_pattern_found?(critical, text)
|
57
|
+
end
|
58
|
+
|
59
|
+
if level == :undefined
|
60
|
+
level = :high if severity_pattern_found?(high, text)
|
61
|
+
end
|
62
|
+
|
63
|
+
if level == :undefined
|
64
|
+
level = :medium if severity_pattern_found?(medium, text)
|
65
|
+
end
|
66
|
+
|
67
|
+
if level == :undefined
|
68
|
+
level = :low if severity_pattern_found?(low, text)
|
69
|
+
end
|
70
|
+
|
71
|
+
if level == :undefined
|
72
|
+
level = :info if severity_pattern_found?(info, text)
|
73
|
+
end
|
74
|
+
|
75
|
+
level
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
|
82
|
+
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
module SastBox
|
4
|
+
module Snippet
|
5
|
+
|
6
|
+
def filename_relative(filename)
|
7
|
+
#filename.sub(@opts.codebase, '') if filename.start_with?(@opts.codebase)
|
8
|
+
filename_path = File.expand_path(filename)
|
9
|
+
codebase_path = File.expand_path(@opts.codebase)
|
10
|
+
|
11
|
+
if filename_path.start_with?(codebase_path)
|
12
|
+
filename_path.sub!(codebase_path, '')
|
13
|
+
filename_path = filename_path[1..-1] if filename_path.start_with?('/')
|
14
|
+
return filename_path
|
15
|
+
else
|
16
|
+
#print_warning("Filename outside codebase => #{filename_path}")
|
17
|
+
return nil
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def snippet_calculate_hashes(snippet)
|
22
|
+
snippet[:evidence_line][:hash] = Digest::SHA256.hexdigest(snippet[:evidence_line][:content])
|
23
|
+
snippet[:evidence_full][:hash] = Digest::SHA256.hexdigest(snippet[:evidence_full][:content])
|
24
|
+
end
|
25
|
+
|
26
|
+
def snippet_read(filename, line, context=5)
|
27
|
+
snippet = {
|
28
|
+
evidence_line: { content: '', start_line: 0, end_line: 0, hash: '' },
|
29
|
+
evidence_full: { content: '', start_line: 0, end_line: 0, hash: '' },
|
30
|
+
read_success: false
|
31
|
+
}
|
32
|
+
|
33
|
+
if File.file?(filename)
|
34
|
+
snippet[:read_success] = true
|
35
|
+
lines = File.open(filename).readlines
|
36
|
+
begin_code = [1, line.to_i - context].max
|
37
|
+
end_code = [line.to_i + context, lines.length].min
|
38
|
+
|
39
|
+
if end_code > lines.length or line.to_i > lines.length
|
40
|
+
snippet[:read_success] = false
|
41
|
+
return snippet
|
42
|
+
end
|
43
|
+
|
44
|
+
snippet[:evidence_line][:start_line] = line.to_i
|
45
|
+
snippet[:evidence_line][:end_line] = line.to_i
|
46
|
+
snippet[:evidence_line][:content] = lines[line.to_i - 1].chomp.force_encoding('ISO-8859-1').encode('UTF-8')
|
47
|
+
|
48
|
+
snippet[:evidence_full][:start_line] = begin_code
|
49
|
+
snippet[:evidence_full][:end_line] = end_code
|
50
|
+
|
51
|
+
begin_code.upto(end_code) do |pos|
|
52
|
+
snippet[:evidence_full][:content] << lines[pos - 1].force_encoding('ISO-8859-1').encode('UTF-8')
|
53
|
+
end
|
54
|
+
snippet_calculate_hashes(snippet)
|
55
|
+
#snippet[:evidence_line][:hash] = Digest::SHA256.hexdigest(snippet[:evidence_line][:content])
|
56
|
+
#snippet[:evidence_full][:hash] = Digest::SHA256.hexdigest(snippet[:evidence_full][:content])
|
57
|
+
end
|
58
|
+
|
59
|
+
snippet
|
60
|
+
end
|
61
|
+
|
62
|
+
def snippet_read_range(filename, start_line, end_line, context=5)
|
63
|
+
snippet = {
|
64
|
+
evidence_line: { content: '', start_line: 0, end_line: 0, hash: '' },
|
65
|
+
evidence_full: { content: '', start_line: 0, end_line: 0, hash: '' },
|
66
|
+
read_success: false
|
67
|
+
}
|
68
|
+
|
69
|
+
if File.file?(filename)
|
70
|
+
snippet[:read_success] = true
|
71
|
+
lines = File.open(filename).readlines
|
72
|
+
num_lines = lines.length
|
73
|
+
|
74
|
+
if !start_line.between?(1, num_lines) || !end_line.between?(1, num_lines) || start_line > end_line
|
75
|
+
snippet[:read_success] = false
|
76
|
+
return snippet
|
77
|
+
end
|
78
|
+
|
79
|
+
begin_code = [1, start_line.to_i - context].max
|
80
|
+
end_code = [end_line.to_i + context, num_lines].min
|
81
|
+
|
82
|
+
if end_code > num_lines
|
83
|
+
snippet[:read_success] = false
|
84
|
+
return snippet
|
85
|
+
end
|
86
|
+
|
87
|
+
snippet[:evidence_line][:start_line] = start_line.to_i
|
88
|
+
snippet[:evidence_line][:end_line] = end_line.to_i
|
89
|
+
|
90
|
+
start_line.upto(end_line) do |pos|
|
91
|
+
snippet[:evidence_line][:content] << lines[pos - 1].force_encoding('ISO-8859-1').encode('UTF-8')
|
92
|
+
end
|
93
|
+
|
94
|
+
snippet[:evidence_full][:start_line] = begin_code
|
95
|
+
snippet[:evidence_full][:end_line] = end_code
|
96
|
+
|
97
|
+
begin_code.upto(end_code) do |pos|
|
98
|
+
snippet[:evidence_full][:content] << lines[pos - 1].force_encoding('ISO-8859-1').encode('UTF-8')
|
99
|
+
end
|
100
|
+
|
101
|
+
snippet_calculate_hashes(snippet)
|
102
|
+
end
|
103
|
+
|
104
|
+
snippet
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|