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.
@@ -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