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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Zwischen
6
+ class Hooks
7
+ HOOK_MARKER = "Zwischen pre-push hook"
8
+
9
+ def self.hook_path(project_root = Dir.pwd)
10
+ File.join(project_root, ".git", "hooks", "pre-push")
11
+ end
12
+
13
+ def self.zwischen_hook?(hook_path)
14
+ return false unless File.exist?(hook_path)
15
+
16
+ File.read(hook_path).include?(HOOK_MARKER)
17
+ end
18
+
19
+ def self.installed?(project_root = Dir.pwd)
20
+ path = hook_path(project_root)
21
+ zwischen_hook?(path)
22
+ end
23
+
24
+ def self.install(project_root = Dir.pwd)
25
+ path = hook_path(project_root)
26
+ hooks_dir = File.dirname(path)
27
+
28
+ # Ensure hooks directory exists
29
+ FileUtils.mkdir_p(hooks_dir) unless File.directory?(hooks_dir)
30
+
31
+ hook_content = <<~HOOK
32
+ #!/usr/bin/env bash
33
+ # #{HOOK_MARKER} - installed by 'zwischen init'
34
+
35
+ if [ "$ZWISCHEN_SKIP" = "1" ]; then
36
+ exit 0
37
+ fi
38
+
39
+ zwischen scan --pre-push
40
+ exit $?
41
+ HOOK
42
+
43
+ File.write(path, hook_content)
44
+ File.chmod(0o755, path)
45
+
46
+ true
47
+ end
48
+
49
+ def self.handle_existing_hook(hook_path, shell)
50
+ return :skip unless File.exist?(hook_path)
51
+ return :install if zwischen_hook?(hook_path) # Already a Zwischen hook, can overwrite
52
+
53
+ shell.say("\n⚠️ A pre-push hook already exists at #{hook_path}", :yellow)
54
+ choice = shell.ask("What would you like to do?", limited_to: %w[backup append skip], default: "backup")
55
+
56
+ case choice
57
+ when "backup"
58
+ backup_path = "#{hook_path}.zwischen.backup"
59
+ FileUtils.cp(hook_path, backup_path)
60
+ shell.say(" ✓ Backed up to #{backup_path}", :green)
61
+ :install
62
+ when "append"
63
+ existing_content = File.read(hook_path)
64
+ new_content = <<~APPEND
65
+ #{existing_content}
66
+
67
+ # #{HOOK_MARKER} - appended by 'zwischen init'
68
+ if [ "$ZWISCHEN_SKIP" = "1" ]; then
69
+ exit 0
70
+ fi
71
+
72
+ zwischen scan --pre-push || exit $?
73
+ APPEND
74
+ File.write(hook_path, new_content)
75
+ File.chmod(0o755, hook_path)
76
+ shell.say(" ✓ Appended Zwischen check to existing hook", :green)
77
+ :skip # Don't install new hook, already appended
78
+ when "skip"
79
+ shell.say(" ↳ Skipping hook installation", :yellow)
80
+ :skip
81
+ end
82
+ end
83
+
84
+ def self.uninstall(project_root = Dir.pwd)
85
+ path = hook_path(project_root)
86
+ return false unless File.exist?(path)
87
+ return false unless zwischen_hook?(path)
88
+
89
+ File.delete(path)
90
+ true
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+ require "net/http"
6
+ require "json"
7
+ require "fileutils"
8
+
9
+ module Zwischen
10
+ class Installer
11
+ ZWISCHEN_BIN_DIR = File.expand_path("~/.zwischen/bin")
12
+ GITLEAKS_REPO = "gitleaks/gitleaks"
13
+
14
+ PLATFORMS = {
15
+ darwin: "macos",
16
+ linux: "linux",
17
+ mingw: "windows",
18
+ mswin: "windows"
19
+ }.freeze
20
+
21
+ # Map Ruby platform to gitleaks release naming
22
+ GITLEAKS_PLATFORMS = {
23
+ "linux" => "linux",
24
+ "macos" => "darwin"
25
+ }.freeze
26
+
27
+ GITLEAKS_ARCHS = {
28
+ "x86_64" => "x64",
29
+ "amd64" => "x64",
30
+ "aarch64" => "arm64",
31
+ "arm64" => "arm64"
32
+ }.freeze
33
+
34
+ INSTALL_COMMANDS = {
35
+ gitleaks: {
36
+ macos: {
37
+ brew: "brew install gitleaks",
38
+ manual: "Visit https://github.com/gitleaks/gitleaks/releases"
39
+ },
40
+ linux: {
41
+ brew: "brew install gitleaks",
42
+ manual: "Visit https://github.com/gitleaks/gitleaks/releases"
43
+ },
44
+ windows: {
45
+ manual: "Visit https://github.com/gitleaks/gitleaks/releases"
46
+ }
47
+ },
48
+ semgrep: {
49
+ macos: {
50
+ brew: "brew install semgrep",
51
+ pip: "pip install semgrep",
52
+ manual: "Visit https://semgrep.dev/docs/getting-started/"
53
+ },
54
+ linux: {
55
+ pip: "pip install semgrep",
56
+ pipx: "pipx install semgrep",
57
+ manual: "Visit https://semgrep.dev/docs/getting-started/"
58
+ },
59
+ windows: {
60
+ pip: "pip install semgrep",
61
+ manual: "Visit https://semgrep.dev/docs/getting-started/"
62
+ }
63
+ }
64
+ }.freeze
65
+
66
+ def self.platform
67
+ new.platform
68
+ end
69
+
70
+ def self.install_commands(tool, platform = nil)
71
+ new.install_commands(tool, platform)
72
+ end
73
+
74
+ def platform
75
+ host_os = RbConfig::CONFIG["host_os"].downcase
76
+ case host_os
77
+ when /darwin/
78
+ "macos"
79
+ when /linux/
80
+ "linux"
81
+ when /mingw|mswin/
82
+ "windows"
83
+ else
84
+ "unknown"
85
+ end
86
+ end
87
+
88
+ def install_commands(tool, platform = nil)
89
+ platform ||= self.platform
90
+ INSTALL_COMMANDS.dig(tool.to_sym, platform.to_sym) || {}
91
+ end
92
+
93
+ def preferred_command(tool, platform = nil)
94
+ platform ||= self.platform
95
+ commands = install_commands(tool, platform)
96
+
97
+ # Prefer brew on macOS, pip on Linux
98
+ if platform == "macos" && commands[:brew]
99
+ commands[:brew]
100
+ elsif commands[:pip]
101
+ commands[:pip]
102
+ elsif commands[:brew]
103
+ commands[:brew]
104
+ else
105
+ commands[:manual]
106
+ end
107
+ end
108
+
109
+ def check_tool(tool_name)
110
+ system("which", tool_name, out: File::NULL, err: File::NULL)
111
+ end
112
+
113
+ def get_version(tool_name)
114
+ return nil unless check_tool(tool_name)
115
+
116
+ stdout, _stderr, status = Open3.capture3(tool_name, "--version")
117
+ return nil unless status.success?
118
+
119
+ stdout.strip.split("\n").first
120
+ rescue StandardError
121
+ nil
122
+ end
123
+
124
+ # Auto-install gitleaks binary if not present
125
+ def auto_install_gitleaks
126
+ return true if gitleaks_available?
127
+
128
+ FileUtils.mkdir_p(ZWISCHEN_BIN_DIR)
129
+
130
+ release = fetch_latest_gitleaks_release
131
+ return false unless release
132
+
133
+ asset = find_gitleaks_asset(release)
134
+ return false unless asset
135
+
136
+ download_and_extract_gitleaks(asset)
137
+ end
138
+
139
+ # Check if gitleaks is available (local or system)
140
+ def gitleaks_available?
141
+ !gitleaks_path.nil?
142
+ end
143
+
144
+ # Get path to gitleaks executable (local install or system)
145
+ def gitleaks_path
146
+ local = File.join(ZWISCHEN_BIN_DIR, "gitleaks")
147
+ return local if File.executable?(local)
148
+
149
+ check_tool("gitleaks") ? "gitleaks" : nil
150
+ end
151
+
152
+ private
153
+
154
+ def fetch_latest_gitleaks_release
155
+ uri = URI("https://api.github.com/repos/#{GITLEAKS_REPO}/releases/latest")
156
+ http = Net::HTTP.new(uri.host, uri.port)
157
+ http.use_ssl = true
158
+
159
+ request = Net::HTTP::Get.new(uri)
160
+ request["Accept"] = "application/vnd.github.v3+json"
161
+ request["User-Agent"] = "Zwischen"
162
+
163
+ response = http.request(request)
164
+ return nil unless response.is_a?(Net::HTTPSuccess)
165
+
166
+ JSON.parse(response.body)
167
+ rescue StandardError => e
168
+ warn "Failed to fetch gitleaks release: #{e.message}" if ENV["DEBUG"]
169
+ nil
170
+ end
171
+
172
+ def find_gitleaks_asset(release)
173
+ gitleaks_platform = GITLEAKS_PLATFORMS[platform]
174
+ return nil unless gitleaks_platform
175
+
176
+ arch = GITLEAKS_ARCHS[RbConfig::CONFIG["host_cpu"]] || "x64"
177
+
178
+ # Asset name pattern: gitleaks_8.18.0_linux_x64.tar.gz
179
+ pattern = /gitleaks_.*_#{gitleaks_platform}_#{arch}\.tar\.gz$/
180
+
181
+ release["assets"]&.find { |a| a["name"] =~ pattern }
182
+ end
183
+
184
+ def download_and_extract_gitleaks(asset)
185
+ require "open-uri"
186
+ require "rubygems/package"
187
+ require "zlib"
188
+ require "stringio"
189
+
190
+ download_url = asset["browser_download_url"]
191
+ target_path = File.join(ZWISCHEN_BIN_DIR, "gitleaks")
192
+
193
+ # Download tarball
194
+ tarball = URI.open(download_url, "User-Agent" => "Zwischen").read
195
+
196
+ # Extract gitleaks binary from tar.gz
197
+ Zlib::GzipReader.wrap(StringIO.new(tarball)) do |gz|
198
+ Gem::Package::TarReader.new(gz) do |tar|
199
+ tar.each do |entry|
200
+ if entry.full_name == "gitleaks"
201
+ File.open(target_path, "wb") { |f| f.write(entry.read) }
202
+ File.chmod(0o755, target_path)
203
+ return true
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ false
210
+ rescue StandardError => e
211
+ warn "Failed to install gitleaks: #{e.message}" if ENV["DEBUG"]
212
+ false
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Zwischen
7
+ class ProjectDetector
8
+ # Base detection patterns for runtime/language
9
+ DETECTION_PATTERNS = {
10
+ "node" => ["package.json"],
11
+ "python" => ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "poetry.lock"],
12
+ "ruby" => ["Gemfile", "Rakefile"],
13
+ "go" => ["go.mod", "go.sum"],
14
+ "java" => ["pom.xml", "build.gradle", "build.gradle.kts"],
15
+ "rust" => ["Cargo.toml", "Cargo.lock"],
16
+ "php" => ["composer.json"],
17
+ "dotnet" => ["*.csproj", "*.sln", "*.fsproj"]
18
+ }.freeze
19
+
20
+ # Framework detection in package.json dependencies
21
+ JS_FRAMEWORKS = {
22
+ "nextjs" => ["next"],
23
+ "react" => ["react"],
24
+ "vue" => ["vue"],
25
+ "angular" => ["@angular/core"],
26
+ "svelte" => ["svelte"],
27
+ "express" => ["express"],
28
+ "nestjs" => ["@nestjs/core"],
29
+ "nuxt" => ["nuxt"],
30
+ "remix" => ["@remix-run/react"],
31
+ "astro" => ["astro"],
32
+ "gatsby" => ["gatsby"]
33
+ }.freeze
34
+
35
+ # Framework detection in Python dependencies
36
+ PYTHON_FRAMEWORKS = {
37
+ "django" => ["django", "Django"],
38
+ "fastapi" => ["fastapi", "FastAPI"],
39
+ "flask" => ["flask", "Flask"],
40
+ "pyramid" => ["pyramid"],
41
+ "tornado" => ["tornado"],
42
+ "starlette" => ["starlette"],
43
+ "streamlit" => ["streamlit"],
44
+ "jupyter" => ["jupyter", "jupyterlab", "notebook"]
45
+ }.freeze
46
+
47
+ # Framework detection in Gemfile
48
+ RUBY_FRAMEWORKS = {
49
+ "rails" => ["rails"],
50
+ "sinatra" => ["sinatra"],
51
+ "hanami" => ["hanami"],
52
+ "grape" => ["grape"],
53
+ "roda" => ["roda"]
54
+ }.freeze
55
+
56
+ # Map frameworks to primary language
57
+ FRAMEWORK_LANGUAGES = {
58
+ "nextjs" => "javascript", "react" => "javascript", "vue" => "javascript",
59
+ "angular" => "typescript", "svelte" => "javascript", "express" => "javascript",
60
+ "nestjs" => "typescript", "nuxt" => "javascript", "remix" => "javascript",
61
+ "astro" => "javascript", "gatsby" => "javascript",
62
+ "django" => "python", "fastapi" => "python", "flask" => "python",
63
+ "pyramid" => "python", "tornado" => "python", "starlette" => "python",
64
+ "streamlit" => "python", "jupyter" => "python",
65
+ "rails" => "ruby", "sinatra" => "ruby", "hanami" => "ruby",
66
+ "grape" => "ruby", "roda" => "ruby"
67
+ }.freeze
68
+
69
+ def self.detect(project_root = Dir.pwd)
70
+ new(project_root).detect
71
+ end
72
+
73
+ def initialize(project_root = Dir.pwd)
74
+ @project_root = project_root
75
+ end
76
+
77
+ def detect
78
+ detected_types = detect_base_types
79
+ frameworks = detect_frameworks
80
+
81
+ # Determine primary type - prefer framework over base type
82
+ primary = frameworks.first || detected_types.first
83
+
84
+ # Determine language
85
+ language = if frameworks.any?
86
+ FRAMEWORK_LANGUAGES[frameworks.first] || detected_types.first
87
+ else
88
+ detected_types.first
89
+ end
90
+
91
+ {
92
+ types: detected_types,
93
+ primary_type: primary,
94
+ language: language || "unknown",
95
+ frameworks: frameworks,
96
+ root: @project_root
97
+ }
98
+ end
99
+
100
+ private
101
+
102
+ def detect_base_types
103
+ detected = []
104
+
105
+ DETECTION_PATTERNS.each do |type, patterns|
106
+ if patterns.any? { |pattern| matches_pattern?(pattern) }
107
+ detected << type
108
+ end
109
+ end
110
+
111
+ detected
112
+ end
113
+
114
+ def detect_frameworks
115
+ frameworks = []
116
+
117
+ # Detect JS frameworks from package.json
118
+ frameworks.concat(detect_js_frameworks)
119
+
120
+ # Detect Python frameworks
121
+ frameworks.concat(detect_python_frameworks)
122
+
123
+ # Detect Ruby frameworks
124
+ frameworks.concat(detect_ruby_frameworks)
125
+
126
+ frameworks.uniq
127
+ end
128
+
129
+ def detect_js_frameworks
130
+ package_json_path = File.join(@project_root, "package.json")
131
+ return [] unless File.exist?(package_json_path)
132
+
133
+ begin
134
+ package = JSON.parse(File.read(package_json_path))
135
+ all_deps = (package["dependencies"] || {}).keys +
136
+ (package["devDependencies"] || {}).keys
137
+
138
+ detected = []
139
+ JS_FRAMEWORKS.each do |framework, packages|
140
+ if packages.any? { |pkg| all_deps.include?(pkg) }
141
+ detected << framework
142
+ end
143
+ end
144
+
145
+ # Sort by specificity (Next.js before React, etc.)
146
+ sort_by_specificity(detected, %w[nextjs nuxt remix gatsby astro angular nestjs svelte vue react express])
147
+ rescue JSON::ParserError
148
+ []
149
+ end
150
+ end
151
+
152
+ def detect_python_frameworks
153
+ frameworks = []
154
+
155
+ # Check requirements.txt
156
+ req_path = File.join(@project_root, "requirements.txt")
157
+ if File.exist?(req_path)
158
+ content = File.read(req_path).downcase
159
+ frameworks.concat(match_python_deps(content))
160
+ end
161
+
162
+ # Check pyproject.toml
163
+ pyproject_path = File.join(@project_root, "pyproject.toml")
164
+ if File.exist?(pyproject_path)
165
+ content = File.read(pyproject_path).downcase
166
+ frameworks.concat(match_python_deps(content))
167
+ end
168
+
169
+ # Check Pipfile
170
+ pipfile_path = File.join(@project_root, "Pipfile")
171
+ if File.exist?(pipfile_path)
172
+ content = File.read(pipfile_path).downcase
173
+ frameworks.concat(match_python_deps(content))
174
+ end
175
+
176
+ sort_by_specificity(frameworks.uniq, %w[django fastapi flask pyramid tornado starlette streamlit jupyter])
177
+ end
178
+
179
+ def match_python_deps(content)
180
+ detected = []
181
+ PYTHON_FRAMEWORKS.each do |framework, packages|
182
+ if packages.any? { |pkg| content.include?(pkg.downcase) }
183
+ detected << framework
184
+ end
185
+ end
186
+ detected
187
+ end
188
+
189
+ def detect_ruby_frameworks
190
+ gemfile_path = File.join(@project_root, "Gemfile")
191
+ return [] unless File.exist?(gemfile_path)
192
+
193
+ content = File.read(gemfile_path).downcase
194
+ detected = []
195
+
196
+ RUBY_FRAMEWORKS.each do |framework, gems|
197
+ if gems.any? { |gem| content.include?("gem '#{gem}'") || content.include?("gem \"#{gem}\"") }
198
+ detected << framework
199
+ end
200
+ end
201
+
202
+ sort_by_specificity(detected, %w[rails hanami sinatra grape roda])
203
+ end
204
+
205
+ def sort_by_specificity(detected, priority_order)
206
+ detected.sort_by { |f| priority_order.index(f) || 999 }
207
+ end
208
+
209
+ def matches_pattern?(pattern)
210
+ if pattern.include?("*")
211
+ Dir.glob(File.join(@project_root, pattern)).any?
212
+ else
213
+ File.exist?(File.join(@project_root, pattern))
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../version"
5
+
6
+ module Zwischen
7
+ module Reporter
8
+ # Renders findings as SARIF 2.1.0 for GitHub code scanning and other
9
+ # SARIF consumers (zwischen scan --format sarif).
10
+ class Sarif
11
+ SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
12
+
13
+ SEVERITY_LEVELS = {
14
+ "critical" => "error",
15
+ "high" => "error",
16
+ "medium" => "warning",
17
+ "low" => "note",
18
+ "info" => "note"
19
+ }.freeze
20
+
21
+ # GitHub uses security-severity to bucket alerts (9.0+ critical, 7.0+ high...)
22
+ SECURITY_SEVERITY = {
23
+ "critical" => "9.5",
24
+ "high" => "8.0",
25
+ "medium" => "5.0",
26
+ "low" => "3.0",
27
+ "info" => "1.0"
28
+ }.freeze
29
+
30
+ def self.report(aggregated_results, project_root: Dir.pwd)
31
+ new(aggregated_results, project_root: project_root).render
32
+ end
33
+
34
+ def initialize(aggregated_results, project_root: Dir.pwd)
35
+ @findings = aggregated_results[:findings]
36
+ @project_root = project_root
37
+ end
38
+
39
+ def render
40
+ JSON.pretty_generate(
41
+ "$schema" => SCHEMA,
42
+ "version" => "2.1.0",
43
+ "runs" => [run]
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ def run
50
+ {
51
+ "tool" => {
52
+ "driver" => {
53
+ "name" => "Zwischen",
54
+ "version" => Zwischen::VERSION,
55
+ "informationUri" => "https://github.com/cjordan223/zwischen",
56
+ "rules" => rules
57
+ }
58
+ },
59
+ "results" => results
60
+ }
61
+ end
62
+
63
+ def rules
64
+ @findings.map { |f| rule_id(f) }.uniq.map do |id|
65
+ finding = @findings.find { |f| rule_id(f) == id }
66
+ {
67
+ "id" => id,
68
+ "shortDescription" => { "text" => finding.message },
69
+ "properties" => {
70
+ "security-severity" => SECURITY_SEVERITY.fetch(finding.severity, "5.0"),
71
+ "tags" => ["security", finding.type]
72
+ }
73
+ }
74
+ end
75
+ end
76
+
77
+ def results
78
+ @findings.map do |finding|
79
+ {
80
+ "ruleId" => rule_id(finding),
81
+ "level" => SEVERITY_LEVELS.fetch(finding.severity, "warning"),
82
+ "message" => { "text" => message_for(finding) },
83
+ "locations" => [{
84
+ "physicalLocation" => {
85
+ "artifactLocation" => { "uri" => relative_uri(finding.file) },
86
+ "region" => { "startLine" => [finding.line || 1, 1].max }
87
+ }
88
+ }]
89
+ }
90
+ end
91
+ end
92
+
93
+ def rule_id(finding)
94
+ finding.rule_id || "#{finding.scanner}/#{finding.type}"
95
+ end
96
+
97
+ def message_for(finding)
98
+ parts = [finding.message]
99
+ if finding.raw_data["ai_fix_suggestion"]
100
+ parts << "Fix: #{finding.raw_data['ai_fix_suggestion']}"
101
+ end
102
+ parts.join(" ")
103
+ end
104
+
105
+ def relative_uri(path)
106
+ expanded = File.expand_path(path.to_s)
107
+ roots = [@project_root, (File.realpath(@project_root) rescue @project_root)].uniq
108
+ roots.each do |root|
109
+ return expanded.delete_prefix("#{root}/") if expanded.start_with?("#{root}/")
110
+ end
111
+ path.to_s
112
+ end
113
+ end
114
+ end
115
+ end