zwischen 0.1.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/.zwischen.yml.example +49 -0
- data/CHANGELOG.md +21 -0
- data/DEVELOPMENT.md +154 -0
- data/README.md +207 -0
- data/TESTING.md +374 -0
- data/bin/zwischen +7 -0
- data/lib/zwischen/ai/analyzer.rb +121 -0
- data/lib/zwischen/ai/anthropic_client.rb +59 -0
- data/lib/zwischen/ai/base_client.rb +27 -0
- data/lib/zwischen/ai/ollama_client.rb +59 -0
- data/lib/zwischen/ai/openai_client.rb +56 -0
- data/lib/zwischen/cli.rb +225 -0
- data/lib/zwischen/config.rb +159 -0
- data/lib/zwischen/credentials.rb +68 -0
- data/lib/zwischen/finding/aggregator.rb +78 -0
- data/lib/zwischen/finding/finding.rb +85 -0
- data/lib/zwischen/git_diff.rb +74 -0
- data/lib/zwischen/hooks.rb +93 -0
- data/lib/zwischen/installer.rb +215 -0
- data/lib/zwischen/project_detector.rb +217 -0
- data/lib/zwischen/reporter/sarif.rb +115 -0
- data/lib/zwischen/reporter/terminal.rb +190 -0
- data/lib/zwischen/scanner/base.rb +89 -0
- data/lib/zwischen/scanner/gitleaks.rb +115 -0
- data/lib/zwischen/scanner/orchestrator.rb +99 -0
- data/lib/zwischen/scanner/semgrep.rb +78 -0
- data/lib/zwischen/setup.rb +167 -0
- data/lib/zwischen/version.rb +5 -0
- data/lib/zwischen.rb +29 -0
- data/zwischen.gemspec +34 -0
- metadata +145 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "colorize"
|
|
4
|
+
require_relative "../finding/finding"
|
|
5
|
+
|
|
6
|
+
module Zwischen
|
|
7
|
+
module Reporter
|
|
8
|
+
class Terminal
|
|
9
|
+
SEVERITY_COLORS = {
|
|
10
|
+
"critical" => :red,
|
|
11
|
+
"high" => :red,
|
|
12
|
+
"medium" => :yellow,
|
|
13
|
+
"low" => :blue,
|
|
14
|
+
"info" => :cyan
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
SEVERITY_BADGES = {
|
|
18
|
+
"critical" => "🔴 CRITICAL",
|
|
19
|
+
"high" => "🔴 HIGH",
|
|
20
|
+
"medium" => "🟡 MEDIUM",
|
|
21
|
+
"low" => "🔵 LOW",
|
|
22
|
+
"info" => "ℹ️ INFO"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def self.report(aggregated_results, ai_enabled: false)
|
|
26
|
+
new(aggregated_results, ai_enabled: ai_enabled).report
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.report_compact(aggregated_results, config:, ai_enabled: false)
|
|
30
|
+
new(aggregated_results, ai_enabled: ai_enabled, config: config).report_compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(aggregated_results, ai_enabled: false, config: nil)
|
|
34
|
+
@results = aggregated_results
|
|
35
|
+
@ai_enabled = ai_enabled
|
|
36
|
+
@config = config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Show paths relative to the working directory when they live under it.
|
|
40
|
+
# Scanners may emit symlink-resolved absolute paths (/tmp vs /private/tmp
|
|
41
|
+
# on macOS), so compare against the resolved cwd too.
|
|
42
|
+
def display_path(path)
|
|
43
|
+
expanded = File.expand_path(path.to_s)
|
|
44
|
+
[Dir.pwd, (File.realpath(Dir.pwd) rescue Dir.pwd)].uniq.each do |root|
|
|
45
|
+
return expanded.delete_prefix("#{root}/") if expanded.start_with?("#{root}/")
|
|
46
|
+
end
|
|
47
|
+
path.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def report
|
|
51
|
+
print_summary
|
|
52
|
+
print_findings
|
|
53
|
+
exit_code
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def report_compact
|
|
57
|
+
blocking_severity = @config&.blocking_severity || "high"
|
|
58
|
+
findings = @results[:findings]
|
|
59
|
+
|
|
60
|
+
# Filter to only blocking findings
|
|
61
|
+
blocking_findings = findings.select { |f| should_block?(f, blocking_severity) }
|
|
62
|
+
|
|
63
|
+
# If no blocking findings, exit silently (exit code 0)
|
|
64
|
+
return 0 if blocking_findings.empty?
|
|
65
|
+
|
|
66
|
+
# Show compact output for blocking findings
|
|
67
|
+
puts "🛡️ Zwischen: #{blocking_findings.length} issue#{blocking_findings.length == 1 ? '' : 's'} found\n\n"
|
|
68
|
+
|
|
69
|
+
blocking_findings.each do |finding|
|
|
70
|
+
severity_color = SEVERITY_COLORS[finding.severity] || :white
|
|
71
|
+
severity_label = finding.severity.upcase
|
|
72
|
+
|
|
73
|
+
puts " #{severity_label}".colorize(severity_color) + " #{display_path(finding.file)}:#{finding.line || '?'}"
|
|
74
|
+
puts " #{finding.message}"
|
|
75
|
+
|
|
76
|
+
# Show fix suggestion if available
|
|
77
|
+
if @ai_enabled && finding.raw_data["ai_fix_suggestion"]
|
|
78
|
+
puts " → #{finding.raw_data['ai_fix_suggestion']}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
puts ""
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
puts "Push blocked. Fix issues above or:"
|
|
85
|
+
puts " • Run 'zwischen scan' for full report"
|
|
86
|
+
puts " • Run 'git push --no-verify' to skip (not recommended)"
|
|
87
|
+
|
|
88
|
+
1 # Exit code 1 = push blocked
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def print_summary
|
|
94
|
+
summary = @results[:summary]
|
|
95
|
+
puts "\n" + "=" * 60
|
|
96
|
+
puts "Zwischen Security Scan Results".colorize(:bold)
|
|
97
|
+
puts "=" * 60
|
|
98
|
+
puts "\nTotal Findings: #{summary[:total]}".colorize(:bold)
|
|
99
|
+
|
|
100
|
+
if summary[:by_severity].any?
|
|
101
|
+
puts "\nBy Severity:"
|
|
102
|
+
summary[:by_severity].each do |severity, count|
|
|
103
|
+
color = SEVERITY_COLORS[severity] || :white
|
|
104
|
+
puts " #{severity.capitalize}: #{count}".colorize(color)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
puts "\n" + "-" * 60
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def print_findings
|
|
112
|
+
findings = @results[:findings]
|
|
113
|
+
return if findings.empty?
|
|
114
|
+
|
|
115
|
+
puts "\nFindings:\n\n"
|
|
116
|
+
|
|
117
|
+
@results[:grouped].each do |file, file_findings|
|
|
118
|
+
puts "📄 #{display_path(file)}".colorize(:bold)
|
|
119
|
+
puts "-" * 60
|
|
120
|
+
|
|
121
|
+
file_findings.each do |finding|
|
|
122
|
+
print_finding(finding)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
puts "\n"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def print_finding(finding)
|
|
130
|
+
# Skip false positives if AI analysis marked them
|
|
131
|
+
if @ai_enabled && finding.raw_data["ai_false_positive"]
|
|
132
|
+
puts " ⚠️ [FALSE POSITIVE] #{finding.message}".colorize(:light_black)
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
severity_color = SEVERITY_COLORS[finding.severity] || :white
|
|
137
|
+
badge = SEVERITY_BADGES[finding.severity] || finding.severity.upcase
|
|
138
|
+
|
|
139
|
+
puts " #{badge}".colorize(severity_color) + " #{display_path(finding.file)}:#{finding.line || '?'}"
|
|
140
|
+
puts " #{finding.message}"
|
|
141
|
+
|
|
142
|
+
if finding.rule_id
|
|
143
|
+
puts " Rule: #{finding.rule_id}".colorize(:light_black)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if finding.code_snippet
|
|
147
|
+
snippet = finding.code_snippet.split("\n").first(3).join("\n")
|
|
148
|
+
puts " Code:".colorize(:light_black)
|
|
149
|
+
puts " #{snippet}".colorize(:light_black)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# AI recommendations
|
|
153
|
+
if @ai_enabled && finding.raw_data["ai_fix_suggestion"]
|
|
154
|
+
puts " 💡 Fix: #{finding.raw_data['ai_fix_suggestion']}".colorize(:green)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if @ai_enabled && finding.raw_data["ai_risk_explanation"]
|
|
158
|
+
puts " ⚠️ Risk: #{finding.raw_data['ai_risk_explanation']}".colorize(:yellow)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
puts ""
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def should_block?(finding, blocking_severity)
|
|
165
|
+
return false if @ai_enabled && finding.raw_data["ai_false_positive"]
|
|
166
|
+
|
|
167
|
+
case blocking_severity
|
|
168
|
+
when "critical"
|
|
169
|
+
finding.critical?
|
|
170
|
+
when "high"
|
|
171
|
+
finding.critical? || finding.high?
|
|
172
|
+
when "none"
|
|
173
|
+
false
|
|
174
|
+
else
|
|
175
|
+
# Default: block on high or critical
|
|
176
|
+
finding.critical? || finding.high?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def exit_code
|
|
181
|
+
findings = @results[:findings]
|
|
182
|
+
blocking_severity = @config&.blocking_severity || "high"
|
|
183
|
+
|
|
184
|
+
blocking = findings.any? { |f| should_block?(f, blocking_severity) }
|
|
185
|
+
|
|
186
|
+
blocking ? 1 : 0
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../installer"
|
|
5
|
+
|
|
6
|
+
module Zwischen
|
|
7
|
+
module Scanner
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :name, :command
|
|
10
|
+
|
|
11
|
+
ZWISCHEN_BIN_DIR = File.expand_path("~/.zwischen/bin")
|
|
12
|
+
|
|
13
|
+
def initialize(name:, command:)
|
|
14
|
+
@name = name
|
|
15
|
+
@command = command
|
|
16
|
+
@executable_path = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def available?
|
|
20
|
+
!executable_path.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Find executable in ~/.zwischen/bin or system PATH
|
|
24
|
+
def executable_path
|
|
25
|
+
@executable_path ||= find_executable(@command)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_executable(name)
|
|
29
|
+
# Check ~/.zwischen/bin first
|
|
30
|
+
local = File.join(ZWISCHEN_BIN_DIR, name)
|
|
31
|
+
return local if File.executable?(local)
|
|
32
|
+
|
|
33
|
+
# Fall back to system PATH
|
|
34
|
+
system("which", name, out: File::NULL, err: File::NULL) ? name : nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scan(project_root = Dir.pwd, files: nil)
|
|
38
|
+
return [] unless available?
|
|
39
|
+
|
|
40
|
+
if files && !files.empty?
|
|
41
|
+
return scan_files(files, project_root) if respond_to?(:scan_files, true)
|
|
42
|
+
command = build_command_for_files(files, project_root)
|
|
43
|
+
else
|
|
44
|
+
command = build_command(project_root)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
stdout, stderr, status = Open3.capture3(*command, chdir: project_root)
|
|
48
|
+
|
|
49
|
+
# Most security scanners use exit code 0 = clean, 1 = findings found, 2+ = error
|
|
50
|
+
# We treat both 0 and 1 as success since findings are valid results
|
|
51
|
+
if status.exitstatus <= 1
|
|
52
|
+
parse_output(stdout)
|
|
53
|
+
else
|
|
54
|
+
warn "Warning: #{@name} scan failed (exit #{status.exitstatus}): #{stderr}" unless stderr.empty?
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
warn "Error running #{@name}: #{e.message}"
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_output(_output)
|
|
63
|
+
raise NotImplementedError, "Subclasses must implement parse_output"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
protected
|
|
67
|
+
|
|
68
|
+
def build_command(_project_root)
|
|
69
|
+
raise NotImplementedError, "Subclasses must implement build_command"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_command_for_files(_files, _project_root)
|
|
73
|
+
raise NotImplementedError, "Subclasses must implement build_command_for_files"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_file_snippet(file_path, line_number, context_lines = 3)
|
|
77
|
+
return nil unless File.exist?(file_path)
|
|
78
|
+
|
|
79
|
+
lines = File.readlines(file_path)
|
|
80
|
+
start_line = [0, line_number - context_lines - 1].max
|
|
81
|
+
end_line = [lines.length - 1, line_number + context_lines - 1].min
|
|
82
|
+
|
|
83
|
+
lines[start_line..end_line].join
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../finding/finding"
|
|
6
|
+
|
|
7
|
+
module Zwischen
|
|
8
|
+
module Scanner
|
|
9
|
+
class Gitleaks < Base
|
|
10
|
+
def initialize
|
|
11
|
+
super(name: "gitleaks", command: "gitleaks")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build_command(project_root)
|
|
15
|
+
[
|
|
16
|
+
executable_path, "detect",
|
|
17
|
+
"--source", project_root,
|
|
18
|
+
"--report-format", "json",
|
|
19
|
+
"--report-path", "-",
|
|
20
|
+
"--no-git"
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scan_files(files, project_root)
|
|
25
|
+
return [] if files.empty?
|
|
26
|
+
|
|
27
|
+
# Gitleaks doesn't have native multi-file support, so we scan each file individually
|
|
28
|
+
# This is acceptable for pre-push since we typically have only a few changed files
|
|
29
|
+
findings = []
|
|
30
|
+
|
|
31
|
+
files.each do |file|
|
|
32
|
+
file_path = File.join(project_root, file)
|
|
33
|
+
next unless File.exist?(file_path)
|
|
34
|
+
|
|
35
|
+
command = [
|
|
36
|
+
executable_path, "detect",
|
|
37
|
+
"--source", file_path,
|
|
38
|
+
"--report-format", "json",
|
|
39
|
+
"--report-path", "-",
|
|
40
|
+
"--no-git"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
stdout, stderr, status = Open3.capture3(*command, chdir: project_root)
|
|
44
|
+
|
|
45
|
+
# Gitleaks: exit 0 = clean, exit 1 = findings, exit 2+ = error
|
|
46
|
+
if status.exitstatus <= 1
|
|
47
|
+
findings.concat(parse_output(stdout)) unless stdout.strip.empty?
|
|
48
|
+
elsif status.exitstatus > 1
|
|
49
|
+
warn "Warning: #{@name} scan failed on #{file} (exit #{status.exitstatus}): #{stderr}" if ENV["DEBUG"]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
findings
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
warn "Error running #{@name}: #{e.message}"
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_output(output)
|
|
60
|
+
return [] if output.strip.empty?
|
|
61
|
+
|
|
62
|
+
findings = []
|
|
63
|
+
json_data = JSON.parse(output)
|
|
64
|
+
|
|
65
|
+
# Gitleaks returns an array of findings
|
|
66
|
+
Array(json_data).each do |finding|
|
|
67
|
+
findings << Zwischen::Finding::Finding.new(
|
|
68
|
+
type: "secret",
|
|
69
|
+
scanner: "gitleaks",
|
|
70
|
+
severity: map_severity(finding["RuleID"]),
|
|
71
|
+
file: finding["File"],
|
|
72
|
+
line: finding["StartLine"],
|
|
73
|
+
message: finding["Description"] || finding["RuleID"] || "Secret detected",
|
|
74
|
+
rule_id: finding["RuleID"],
|
|
75
|
+
code_snippet: finding["Secret"],
|
|
76
|
+
raw_data: finding
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
findings
|
|
81
|
+
rescue JSON::ParserError => e
|
|
82
|
+
warn "Failed to parse Gitleaks output: #{e.message}"
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def build_command_for_files(files, project_root)
|
|
89
|
+
files.map do |file|
|
|
90
|
+
[
|
|
91
|
+
executable_path, "detect",
|
|
92
|
+
"--source", File.join(project_root, file),
|
|
93
|
+
"--report-format", "json",
|
|
94
|
+
"--report-path", "-",
|
|
95
|
+
"--no-git"
|
|
96
|
+
]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def map_severity(rule_id)
|
|
101
|
+
# Gitleaks doesn't provide severity, so we map based on rule type
|
|
102
|
+
case rule_id.to_s.downcase
|
|
103
|
+
when /aws.*key|api.*key|private.*key|secret.*key/
|
|
104
|
+
"critical"
|
|
105
|
+
when /password|token|credential/
|
|
106
|
+
"high"
|
|
107
|
+
when /key|secret/
|
|
108
|
+
"medium"
|
|
109
|
+
else
|
|
110
|
+
"medium"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "gitleaks"
|
|
5
|
+
require_relative "semgrep"
|
|
6
|
+
|
|
7
|
+
module Zwischen
|
|
8
|
+
module Scanner
|
|
9
|
+
class Orchestrator
|
|
10
|
+
def initialize(config:)
|
|
11
|
+
@config = config
|
|
12
|
+
@scanners = build_scanners
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scan(project_root = Dir.pwd, only: nil, pre_push: false, files: nil)
|
|
16
|
+
enabled_scanners = select_scanners(only)
|
|
17
|
+
available_scanners = enabled_scanners.select(&:available?)
|
|
18
|
+
|
|
19
|
+
if available_scanners.empty?
|
|
20
|
+
warn "No scanners available. Run 'zwischen doctor' to check installation." unless pre_push
|
|
21
|
+
return []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Run scanners in parallel using threads
|
|
25
|
+
threads = available_scanners.map do |scanner|
|
|
26
|
+
Thread.new do
|
|
27
|
+
[scanner.name, scanner.scan(project_root, files: files)]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
results = {}
|
|
32
|
+
threads.each do |thread|
|
|
33
|
+
scanner_name, findings = thread.value
|
|
34
|
+
results[scanner_name] = findings
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Flatten all findings
|
|
38
|
+
# Note: In pre-push mode, we pass file lists to scanners when available
|
|
39
|
+
reject_ignored(results.values.flatten, project_root)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def available_scanners
|
|
43
|
+
@scanners.select(&:available?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def missing_scanners
|
|
47
|
+
@scanners.reject(&:available?)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Drop findings whose file matches an ignore glob from .zwischen.yml.
|
|
53
|
+
# Scanner output may use absolute or project-relative paths, so match
|
|
54
|
+
# against the path relative to project_root.
|
|
55
|
+
def reject_ignored(findings, project_root)
|
|
56
|
+
globs = @config.ignored_paths
|
|
57
|
+
return findings if globs.empty?
|
|
58
|
+
|
|
59
|
+
findings.reject do |finding|
|
|
60
|
+
path = finding.file
|
|
61
|
+
if File.absolute_path?(path)
|
|
62
|
+
path = Pathname.new(path).relative_path_from(project_root).to_s rescue path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
66
|
+
globs.any? do |glob|
|
|
67
|
+
# A trailing "**" under FNM_PATHNAME only matches one path segment;
|
|
68
|
+
# also try "**/*" so "**/dist/**" covers files nested below dist/.
|
|
69
|
+
File.fnmatch?(glob, path, flags) ||
|
|
70
|
+
File.fnmatch?(glob.sub(%r{/\*\*\z}, "/**/*"), path, flags)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_scanners
|
|
76
|
+
scanners = []
|
|
77
|
+
|
|
78
|
+
scanners << Gitleaks.new if @config.scanner_enabled?("gitleaks")
|
|
79
|
+
scanners << Semgrep.new(config: @config.semgrep_config) if @config.scanner_enabled?("semgrep")
|
|
80
|
+
|
|
81
|
+
scanners
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def select_scanners(only)
|
|
85
|
+
return @scanners if only.nil? || only.empty?
|
|
86
|
+
|
|
87
|
+
only_list = only.split(",").map(&:strip)
|
|
88
|
+
scanner_map = {
|
|
89
|
+
"secrets" => "gitleaks",
|
|
90
|
+
"sast" => "semgrep"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
selected = only_list.map { |name| scanner_map[name.downcase] }.compact
|
|
94
|
+
|
|
95
|
+
@scanners.select { |s| selected.include?(s.name) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../finding/finding"
|
|
6
|
+
|
|
7
|
+
module Zwischen
|
|
8
|
+
module Scanner
|
|
9
|
+
class Semgrep < Base
|
|
10
|
+
# Use open ruleset that works without Semgrep login
|
|
11
|
+
DEFAULT_CONFIG = "p/security-audit"
|
|
12
|
+
|
|
13
|
+
def initialize(config: DEFAULT_CONFIG)
|
|
14
|
+
super(name: "semgrep", command: "semgrep")
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_command(project_root)
|
|
19
|
+
[executable_path, "--json", *config_args, project_root]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build_command_for_files(files, _project_root)
|
|
23
|
+
[executable_path, "--json", *config_args, *files]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_output(output)
|
|
27
|
+
return [] if output.strip.empty?
|
|
28
|
+
|
|
29
|
+
findings = []
|
|
30
|
+
json_data = JSON.parse(output)
|
|
31
|
+
|
|
32
|
+
# Semgrep returns results in a "results" array
|
|
33
|
+
Array(json_data["results"]).each do |result|
|
|
34
|
+
severity = map_severity(result["extra"]&.dig("severity"))
|
|
35
|
+
|
|
36
|
+
findings << Zwischen::Finding::Finding.new(
|
|
37
|
+
type: "sast",
|
|
38
|
+
scanner: "semgrep",
|
|
39
|
+
severity: severity,
|
|
40
|
+
file: result["path"],
|
|
41
|
+
line: result["start"]&.dig("line"),
|
|
42
|
+
message: result.dig("extra", "message") || result["message"] || result["check_id"],
|
|
43
|
+
rule_id: result["check_id"],
|
|
44
|
+
code_snippet: result["extra"]&.dig("lines"),
|
|
45
|
+
raw_data: result
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
findings
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
warn "Failed to parse Semgrep output: #{e.message}"
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Accept a comma-separated list of rulesets ("p/security-audit,p/expressjs")
|
|
58
|
+
def config_args
|
|
59
|
+
@config.to_s.split(",").map(&:strip).reject(&:empty?).flat_map { |c| ["--config", c] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def map_severity(severity)
|
|
63
|
+
case severity.to_s.downcase
|
|
64
|
+
when "error", "critical"
|
|
65
|
+
"critical"
|
|
66
|
+
when "warning", "high"
|
|
67
|
+
"high"
|
|
68
|
+
when "info", "medium"
|
|
69
|
+
"medium"
|
|
70
|
+
when "low"
|
|
71
|
+
"low"
|
|
72
|
+
else
|
|
73
|
+
"medium" # Default for unknown
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|