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
data/lib/zwischen/cli.rb
ADDED
|
@@ -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
|