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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "colorize"
6
+ require "pathname"
7
+
8
+ module Zwischen
9
+ class CLI < Thor
10
+ # Disable Thor's pager to prevent help from hanging
11
+ def self.exit_on_failure?
12
+ true
13
+ end
14
+
15
+ # Disable pager for help output
16
+ def help(command = nil, subcommand = false)
17
+ ENV["THOR_PAGER"] = "cat" if ENV["THOR_PAGER"].nil?
18
+ super
19
+ end
20
+
21
+ desc "init", "Initialize Zwischen configuration"
22
+ def init
23
+ Setup.run
24
+ end
25
+
26
+ desc "doctor", "Check if required tools are installed"
27
+ def doctor
28
+ installer = Installer.new
29
+ puts "\n" + "=" * 60
30
+ puts "Zwischen Doctor - Tool Status".colorize(:bold)
31
+ puts "=" * 60 + "\n"
32
+
33
+ tools = {
34
+ "gitleaks" => "Secrets detection",
35
+ "semgrep" => "Static analysis"
36
+ }
37
+
38
+ all_installed = true
39
+
40
+ tools.each do |tool_name, description|
41
+ # Check both ~/.zwischen/bin/ and system PATH
42
+ local_path = File.join(File.expand_path("~/.zwischen/bin"), tool_name)
43
+ installed = File.executable?(local_path) || installer.check_tool(tool_name)
44
+
45
+ if installed
46
+ # Get version from the correct path
47
+ executable = File.executable?(local_path) ? local_path : tool_name
48
+ version = begin
49
+ `#{executable} --version 2>/dev/null`.strip.split("\n").first
50
+ rescue
51
+ nil
52
+ end
53
+
54
+ puts "✓ #{tool_name}".colorize(:green) + " - #{description}"
55
+ puts " Version: #{version}" if version && !version.empty?
56
+ puts " Location: #{executable}" if File.executable?(local_path)
57
+ else
58
+ all_installed = false
59
+ puts "✗ #{tool_name}".colorize(:red) + " - #{description} - NOT FOUND"
60
+ puts " → #{installer.preferred_command(tool_name)}"
61
+ end
62
+ puts ""
63
+ end
64
+
65
+ if all_installed
66
+ puts "✅ All tools are installed and ready!".colorize(:green)
67
+ else
68
+ puts "⚠️ Some tools are missing. Install them using the commands above.".colorize(:yellow)
69
+ end
70
+
71
+ puts ""
72
+ end
73
+
74
+ desc "scan", "Run security scan"
75
+ method_option :only, type: :string, desc: "Only run specific scanners (secrets,sast)"
76
+ method_option :ai, type: :string, desc: "Enable AI analysis (claude)"
77
+ method_option :"api-key", type: :string, desc: "API key for AI provider"
78
+ method_option :format, type: :string, default: "terminal", desc: "Output format (terminal, json, sarif)"
79
+ method_option :"pre-push", type: :boolean, desc: "Pre-push mode (quiet, compact output)"
80
+ method_option :changed, type: :boolean, desc: "Only scan files changed since the default branch"
81
+ def scan
82
+ config = Config.load
83
+ project = ProjectDetector.detect
84
+ pre_push = options[:"pre-push"]
85
+ quiet = pre_push || %w[json sarif].include?(options[:format])
86
+
87
+ # Suppress scanning message in pre-push/machine-readable modes
88
+ unless quiet
89
+ puts "🔍 Scanning #{project[:primary_type] || 'project'}...\n"
90
+ end
91
+
92
+ changed_files = nil
93
+ if pre_push || options[:changed]
94
+ changed_files = GitDiff.changed_files
95
+ changed_files = changed_files.select do |path|
96
+ candidate = path
97
+ candidate = File.join(project[:root], candidate) unless Pathname.new(candidate).absolute?
98
+ File.file?(candidate)
99
+ end
100
+
101
+ if changed_files.empty?
102
+ puts Reporter::Sarif.report({ findings: [] }, project_root: project[:root]) if options[:format] == "sarif"
103
+ exit 0
104
+ end
105
+ end
106
+
107
+ # Run scanners
108
+ orchestrator = Scanner::Orchestrator.new(config: config)
109
+ findings = orchestrator.scan(project[:root], only: options[:only], pre_push: pre_push, files: changed_files)
110
+
111
+ # Filter findings to changed files in pre-push/--changed mode
112
+ # Note: This is a safety net. Scanners receive the file list and should only scan those,
113
+ # but some scanners (like gitleaks) may return paths in different formats. This ensures
114
+ # we only report findings for files the developer actually changed.
115
+ if changed_files
116
+ findings = GitDiff.filter_findings(findings: findings, changed_files: changed_files)
117
+ end
118
+
119
+ if findings.empty?
120
+ # In pre-push mode, exit silently (no output)
121
+ if options[:format] == "sarif"
122
+ puts Reporter::Sarif.report({ findings: [] }, project_root: project[:root])
123
+ elsif !quiet
124
+ puts "✅ No issues found.".colorize(:green)
125
+ end
126
+ exit 0
127
+ end
128
+
129
+ # Aggregate findings
130
+ aggregated = Finding::Aggregator.aggregate(findings)
131
+
132
+ # Determine AI provider
133
+ provider = if options[:ai] && !options[:ai].empty? && options[:ai] != "true"
134
+ options[:ai]
135
+ else
136
+ config.ai_provider
137
+ end
138
+
139
+ # AI analysis if enabled
140
+ ai_enabled = if pre_push
141
+ # In pre-push mode, use config to determine AI
142
+ config.ai_pre_push_enabled?
143
+ else
144
+ # Manual scan: use flag or config
145
+ !options[:ai].nil? || config.ai_enabled?
146
+ end
147
+
148
+ if ai_enabled
149
+ begin
150
+ unless quiet
151
+ puts "🤖 Analyzing findings with AI (#{provider})...\n"
152
+ end
153
+
154
+ api_key = options[:"api-key"] || config.ai_api_key(provider)
155
+ provider_config = config.ai_provider_config(provider)
156
+
157
+ analyzer = AI::Analyzer.new(
158
+ provider: provider,
159
+ api_key: api_key,
160
+ config: provider_config,
161
+ project_context: project
162
+ )
163
+ enhanced_findings = analyzer.analyze(aggregated[:findings])
164
+ aggregated = Finding::Aggregator.aggregate(enhanced_findings)
165
+ rescue AI::Error => e
166
+ warn "⚠️ AI analysis unavailable: #{e.message}" unless pre_push
167
+ # In pre-push mode, continue silently without AI
168
+ end
169
+ end
170
+
171
+ # Report results
172
+ if options[:format] == "json"
173
+ require "json"
174
+ puts JSON.pretty_generate({
175
+ summary: aggregated[:summary],
176
+ findings: aggregated[:findings].map(&:to_h)
177
+ })
178
+ blocking_severity = config.blocking_severity
179
+ exit_code = aggregated[:findings].any? { |f| should_block?(f, blocking_severity, ai_enabled) } ? 1 : 0
180
+ exit exit_code
181
+ elsif options[:format] == "sarif"
182
+ puts Reporter::Sarif.report(aggregated, project_root: project[:root])
183
+ blocking_severity = config.blocking_severity
184
+ exit_code = aggregated[:findings].any? { |f| should_block?(f, blocking_severity, ai_enabled) } ? 1 : 0
185
+ exit exit_code
186
+ else
187
+ if pre_push
188
+ exit_code = Reporter::Terminal.report_compact(aggregated, config: config, ai_enabled: ai_enabled)
189
+ else
190
+ exit_code = Reporter::Terminal.report(aggregated, ai_enabled: ai_enabled)
191
+ end
192
+ exit exit_code
193
+ end
194
+ rescue StandardError => e
195
+ puts "❌ Error: #{e.message}".colorize(:red)
196
+ puts e.backtrace if ENV["DEBUG"]
197
+ exit 1
198
+ end
199
+
200
+ desc "uninstall", "Remove Zwischen git hook and optionally config"
201
+ def uninstall
202
+ Setup.uninstall
203
+ end
204
+
205
+ default_task :scan
206
+
207
+ private
208
+
209
+ def should_block?(finding, blocking_severity, ai_enabled)
210
+ return false if ai_enabled && finding.raw_data["ai_false_positive"]
211
+
212
+ case blocking_severity
213
+ when "critical"
214
+ finding.critical?
215
+ when "high"
216
+ finding.critical? || finding.high?
217
+ when "none"
218
+ false
219
+ else
220
+ # Default: block on high or critical
221
+ finding.critical? || finding.high?
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Zwischen
7
+ class Config
8
+ DEFAULT_CONFIG = {
9
+ "ai" => {
10
+ "enabled" => true,
11
+ "pre_push_enabled" => false,
12
+ "provider" => "claude",
13
+ "api_key" => nil,
14
+ "ollama" => {
15
+ "model" => "llama3",
16
+ "url" => "http://localhost:11434/api/chat"
17
+ },
18
+ "openai" => {
19
+ "model" => "gpt-4"
20
+ }
21
+ },
22
+ "blocking" => {
23
+ "severity" => "high" # high, critical, or none
24
+ },
25
+ "scanners" => {
26
+ "gitleaks" => { "enabled" => true },
27
+ "semgrep" => { "enabled" => true, "config" => "p/security-audit" }
28
+ },
29
+ "ignore" => [
30
+ "**/node_modules/**",
31
+ "**/vendor/**",
32
+ "**/.git/**",
33
+ "**/dist/**",
34
+ "**/build/**",
35
+ "**/test/fixtures/**"
36
+ ],
37
+ "severity" => {
38
+ "fail_on" => ["critical", "high"]
39
+ }
40
+ }.freeze
41
+
42
+ CONFIG_FILE = ".zwischen.yml"
43
+
44
+ def self.load(project_root = Dir.pwd)
45
+ config_path = File.join(project_root, CONFIG_FILE)
46
+ config = DEFAULT_CONFIG.dup
47
+
48
+ if File.exist?(config_path)
49
+ user_config = YAML.safe_load(File.read(config_path))
50
+ config = deep_merge(config, user_config) if user_config
51
+ end
52
+
53
+ new(config)
54
+ end
55
+
56
+ def self.init(project_root = Dir.pwd, quiet: false)
57
+ config_path = File.join(project_root, CONFIG_FILE)
58
+ example_path = File.join(File.dirname(__dir__), "..", ".zwischen.yml.example")
59
+
60
+ if File.exist?(config_path)
61
+ puts "Configuration file already exists at #{config_path}" unless quiet
62
+ return false
63
+ end
64
+
65
+ if File.exist?(example_path)
66
+ FileUtils.cp(example_path, config_path)
67
+ puts "Created #{config_path} from example" unless quiet
68
+ else
69
+ File.write(config_path, DEFAULT_CONFIG.to_yaml)
70
+ puts "Created #{config_path} with default configuration" unless quiet
71
+ end
72
+
73
+ true
74
+ end
75
+
76
+ def initialize(config = {})
77
+ @config = DEFAULT_CONFIG.merge(config)
78
+ end
79
+
80
+ def ai_provider
81
+ @config.dig("ai", "provider") || "claude"
82
+ end
83
+
84
+ def ai_enabled?
85
+ # Default to true if provider is set, otherwise check explicit enabled flag
86
+ provider = ai_provider
87
+ enabled = @config.dig("ai", "enabled")
88
+ enabled.nil? ? !provider.nil? : enabled
89
+ end
90
+
91
+ def ai_pre_push_enabled?
92
+ @config.dig("ai", "pre_push_enabled") == true
93
+ end
94
+
95
+ def ai_api_key(provider = ai_provider)
96
+ # Check credentials first, then config
97
+ begin
98
+ require_relative "credentials"
99
+ api_key = Credentials.get_api_key(provider)
100
+ return api_key if api_key
101
+ rescue LoadError, NameError
102
+ # Credentials not available, fall through to config
103
+ end
104
+ @config.dig("ai", "api_key")
105
+ end
106
+
107
+ def ai_provider_config(provider = ai_provider)
108
+ @config.dig("ai", provider) || {}
109
+ end
110
+
111
+ def blocking_severity
112
+ @config.dig("blocking", "severity") || "high"
113
+ end
114
+
115
+ def scanner_enabled?(scanner)
116
+ scanner_config = @config.dig("scanners", scanner.to_s)
117
+ # Handle both formats: "gitleaks: true" (boolean) and "gitleaks: { enabled: true }" (hash)
118
+ case scanner_config
119
+ when true, false
120
+ scanner_config
121
+ when Hash
122
+ scanner_config["enabled"] != false
123
+ else
124
+ # Default to enabled if not specified
125
+ true
126
+ end
127
+ end
128
+
129
+ def semgrep_config
130
+ semgrep_config = @config.dig("scanners", "semgrep")
131
+ # Handle both formats: "semgrep: true" (boolean) and "semgrep: { enabled: true, config: '...' }" (hash)
132
+ if semgrep_config.is_a?(Hash)
133
+ semgrep_config["config"] || "p/security-audit"
134
+ else
135
+ "p/security-audit"
136
+ end
137
+ end
138
+
139
+ def ignored_paths
140
+ @config["ignore"] || []
141
+ end
142
+
143
+ def fail_on_severities
144
+ @config.dig("severity", "fail_on") || ["critical", "high"]
145
+ end
146
+
147
+ private
148
+
149
+ def self.deep_merge(base, override)
150
+ base.merge(override) do |_key, base_val, override_val|
151
+ if base_val.is_a?(Hash) && override_val.is_a?(Hash)
152
+ deep_merge(base_val, override_val)
153
+ else
154
+ override_val
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Zwischen
7
+ class Credentials
8
+ PROVIDER_ENV_VARS = {
9
+ "claude" => "ANTHROPIC_API_KEY",
10
+ "openai" => "OPENAI_API_KEY"
11
+ }.freeze
12
+
13
+ PROVIDER_KEYS = {
14
+ "claude" => "anthropic_api_key",
15
+ "openai" => "openai_api_key"
16
+ }.freeze
17
+
18
+ def self.credentials_path
19
+ File.join(Dir.home, ".zwischen", "credentials")
20
+ end
21
+
22
+ def self.ensure_directory
23
+ dir = File.dirname(credentials_path)
24
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
25
+ end
26
+
27
+ def self.load
28
+ return {} unless File.exist?(credentials_path)
29
+
30
+ YAML.safe_load(File.read(credentials_path)) || {}
31
+ rescue StandardError => e
32
+ warn "Failed to load credentials: #{e.message}"
33
+ {}
34
+ end
35
+
36
+ def self.save(provider: "claude", api_key:)
37
+ ensure_directory
38
+
39
+ credentials = load
40
+
41
+ key_name = PROVIDER_KEYS[provider]
42
+ if key_name
43
+ credentials[key_name] = api_key
44
+ else
45
+ warn "Unknown provider: #{provider}"
46
+ end
47
+
48
+ File.write(credentials_path, credentials.to_yaml)
49
+ File.chmod(0o600, credentials_path)
50
+ rescue StandardError => e
51
+ warn "Failed to save credentials: #{e.message}"
52
+ raise
53
+ end
54
+
55
+ def self.get_api_key(provider = "claude")
56
+ # Priority: ENV var > credentials file
57
+ env_var = PROVIDER_ENV_VARS[provider]
58
+ env_value = env_var && ENV[env_var]
59
+ return env_value if env_value && !env_value.strip.empty?
60
+
61
+ key_name = PROVIDER_KEYS[provider]
62
+ return nil unless key_name
63
+
64
+ credentials = load
65
+ credentials[key_name]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "finding"
4
+
5
+ module Zwischen
6
+ module Finding
7
+ class Aggregator
8
+ SEVERITY_ORDER = { "critical" => 0, "high" => 1, "medium" => 2, "low" => 3, "info" => 4 }.freeze
9
+
10
+ def self.aggregate(findings)
11
+ new.aggregate(findings)
12
+ end
13
+
14
+ def aggregate(findings)
15
+ normalized = normalize_severities(findings)
16
+ deduplicated = deduplicate(normalized)
17
+ sorted = sort_by_severity(deduplicated)
18
+ grouped = group_by_file(sorted)
19
+
20
+ {
21
+ findings: sorted,
22
+ grouped: grouped,
23
+ summary: build_summary(sorted)
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def normalize_severities(findings)
30
+ findings.map do |finding|
31
+ # Severity is already normalized in Finding class
32
+ finding
33
+ end
34
+ end
35
+
36
+ def deduplicate(findings)
37
+ seen = {}
38
+ findings.select do |finding|
39
+ key = "#{finding.file}:#{finding.line}:#{finding.rule_id}"
40
+ if seen[key]
41
+ false
42
+ else
43
+ seen[key] = true
44
+ true
45
+ end
46
+ end
47
+ end
48
+
49
+ def sort_by_severity(findings)
50
+ findings.sort_by do |finding|
51
+ [
52
+ SEVERITY_ORDER[finding.severity] || 99,
53
+ finding.file,
54
+ finding.line || 0
55
+ ]
56
+ end
57
+ end
58
+
59
+ def group_by_file(findings)
60
+ findings.group_by(&:file)
61
+ end
62
+
63
+ def build_summary(findings)
64
+ summary = {
65
+ total: findings.length,
66
+ by_severity: {}
67
+ }
68
+
69
+ Finding::SEVERITY_LEVELS.each do |severity|
70
+ count = findings.count { |f| f.severity == severity }
71
+ summary[:by_severity][severity] = count if count > 0
72
+ end
73
+
74
+ summary
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zwischen
4
+ module Finding
5
+ class Finding
6
+ attr_reader :type, :scanner, :severity, :file, :line, :message, :rule_id, :code_snippet, :raw_data
7
+
8
+ SEVERITY_LEVELS = %w[critical high medium low info].freeze
9
+
10
+ def initialize(
11
+ type:,
12
+ scanner:,
13
+ severity:,
14
+ file:,
15
+ line: nil,
16
+ message:,
17
+ rule_id: nil,
18
+ code_snippet: nil,
19
+ raw_data: {}
20
+ )
21
+ @type = type.to_s
22
+ @scanner = scanner.to_s
23
+ @severity = normalize_severity(severity)
24
+ @file = file.to_s
25
+ @line = line
26
+ @message = message.to_s
27
+ @rule_id = rule_id.to_s if rule_id
28
+ @code_snippet = code_snippet
29
+ @raw_data = raw_data
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ type: @type,
35
+ scanner: @scanner,
36
+ severity: @severity,
37
+ file: @file,
38
+ line: @line,
39
+ message: @message,
40
+ rule_id: @rule_id,
41
+ code_snippet: @code_snippet,
42
+ raw_data: @raw_data
43
+ }
44
+ end
45
+
46
+ def to_json(*args)
47
+ require "json" unless defined?(JSON)
48
+ to_h.to_json(*args)
49
+ end
50
+
51
+ def critical?
52
+ @severity == "critical"
53
+ end
54
+
55
+ def high?
56
+ @severity == "high"
57
+ end
58
+
59
+ def should_fail?
60
+ critical? || high?
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_severity(severity)
66
+ sev = severity.to_s.downcase
67
+ return sev if SEVERITY_LEVELS.include?(sev)
68
+
69
+ # Map common variations
70
+ case sev
71
+ when /critical|error|fatal/
72
+ "critical"
73
+ when /high|major/
74
+ "high"
75
+ when /medium|moderate|warning/
76
+ "medium"
77
+ when /low|minor|info/
78
+ "low"
79
+ else
80
+ "info"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Zwischen
6
+ class GitDiff
7
+ def self.default_branch
8
+ # Try remote HEAD first (most reliable)
9
+ result = `git remote show origin 2>/dev/null | grep 'HEAD branch'`.strip
10
+ return result.split.last if $?.success? && !result.empty?
11
+
12
+ # Fallback: check if main or master exists locally
13
+ return "main" if system("git show-ref --verify --quiet refs/heads/main >/dev/null 2>&1")
14
+ return "master" if system("git show-ref --verify --quiet refs/heads/master >/dev/null 2>&1")
15
+
16
+ # Last resort
17
+ "HEAD"
18
+ end
19
+
20
+ def self.changed_files(remote: nil, local: "HEAD")
21
+ branch = remote || default_branch
22
+ remote_ref = "origin/#{branch}"
23
+
24
+ # Try remote diff first
25
+ files = `git diff --name-only #{remote_ref}...#{local} 2>/dev/null`.strip.split("\n")
26
+ return files.reject(&:empty?) if $?.success? && !files.empty?
27
+
28
+ # Fallback: local diff
29
+ files = `git diff --name-only HEAD@{1}...#{local} 2>/dev/null`.strip.split("\n")
30
+ return files.reject(&:empty?) if $?.success?
31
+
32
+ []
33
+ rescue StandardError => e
34
+ warn "Failed to get changed files: #{e.message}" if ENV["DEBUG"]
35
+ []
36
+ end
37
+
38
+ def self.filter_findings(findings:, changed_files:)
39
+ return findings if changed_files.empty?
40
+
41
+ # Normalize paths for comparison
42
+ # - Remove leading ./
43
+ # - Convert backslashes to forward slashes
44
+ # - Make relative to project root if absolute
45
+ project_root = Dir.pwd
46
+ normalized_changed = changed_files.map do |f|
47
+ path = f.sub(/^\.\//, "").gsub("\\", "/")
48
+ # If absolute, make relative to project root
49
+ if Pathname.new(path).absolute?
50
+ begin
51
+ Pathname.new(path).relative_path_from(Pathname.new(project_root)).to_s
52
+ rescue ArgumentError
53
+ path
54
+ end
55
+ else
56
+ path
57
+ end
58
+ end
59
+
60
+ findings.select do |f|
61
+ file_path = f.file.sub(/^\.\//, "").gsub("\\", "/")
62
+ # If absolute, make relative to project root
63
+ if Pathname.new(file_path).absolute?
64
+ begin
65
+ file_path = Pathname.new(file_path).relative_path_from(Pathname.new(project_root)).to_s
66
+ rescue ArgumentError
67
+ # Keep original if can't make relative
68
+ end
69
+ end
70
+ normalized_changed.include?(file_path)
71
+ end
72
+ end
73
+ end
74
+ end