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.
data/TESTING.md ADDED
@@ -0,0 +1,374 @@
1
+ # Zwischen End-to-End Testing
2
+
3
+ This guide verifies Zwischen as an installed Ruby gem, not from source. The installed-gem path matters because end users run the generated executable and packaged files.
4
+
5
+ Run the tests from a temporary directory outside the repository, such as `/tmp/zwischen-test-*`.
6
+
7
+ ## Install the Gem Under Test
8
+
9
+ ```bash
10
+ cd /path/to/zwischen
11
+ ./scripts/test_as_gem.sh
12
+
13
+ export PATH="$HOME/.local/share/gem/ruby/$(ruby -e 'puts RUBY_VERSION[/\d+\.\d+/]')/bin:$PATH"
14
+
15
+ which zwischen
16
+ zwischen --help
17
+ ```
18
+
19
+ Expected:
20
+
21
+ - `which zwischen` points at the user gem bin path, not this repository's `bin/zwischen`.
22
+ - `zwischen --help` lists `doctor`, `init`, `scan`, and `uninstall`.
23
+
24
+ ## Test Suite 1: Installation and Init
25
+
26
+ ### Test 1.1: Gem Installation
27
+
28
+ ```bash
29
+ gem list zwischen
30
+ gem which zwischen
31
+ zwischen --help
32
+ ```
33
+
34
+ Expected:
35
+
36
+ - `gem list zwischen` includes the version under test.
37
+ - `gem which zwischen` resolves to the installed gem.
38
+ - Help exits successfully without opening a pager.
39
+
40
+ ### Test 1.2: Init in a Git Repository
41
+
42
+ ```bash
43
+ TEST_DIR=$(mktemp -d -t zwischen-test-XXXXXX)
44
+ cd "$TEST_DIR"
45
+ mkdir test-repo && cd test-repo
46
+ git init
47
+ git config user.email test@example.com
48
+ git config user.name "Zwischen Test"
49
+ printf "# Test\n" > README.md
50
+ git add README.md
51
+ git commit -m "Initial"
52
+ zwischen init
53
+ ```
54
+
55
+ Expected:
56
+
57
+ - `.zwischen.yml` exists.
58
+ - `.git/hooks/pre-push` exists and is executable.
59
+ - The hook contains `Zwischen pre-push hook`.
60
+ - `~/.zwischen/bin/gitleaks` exists when auto-install succeeds, or `zwischen init` prints the manual install command when it cannot auto-install.
61
+ - `~/.zwischen/credentials` is created only when `ANTHROPIC_API_KEY` was set before running `zwischen init`.
62
+
63
+ ### Test 1.3: Config Structure
64
+
65
+ ```bash
66
+ ruby -ryaml -e 'p YAML.safe_load(File.read(".zwischen.yml")).keys'
67
+ zwischen doctor
68
+ ```
69
+
70
+ Expected:
71
+
72
+ - Config includes `ai`, `blocking`, `scanners`, and `ignore`.
73
+ - `zwischen doctor` reports Gitleaks status and Semgrep status without crashing.
74
+
75
+ ## Test Suite 2: Pre-Push Hook
76
+
77
+ ### Test 2.1: Clean Push Path
78
+
79
+ ```bash
80
+ printf "def hello():\n pass\n" > test.py
81
+ git add test.py
82
+ git commit -m "Add clean file"
83
+ .git/hooks/pre-push
84
+ echo $?
85
+ ```
86
+
87
+ Expected:
88
+
89
+ - Hook exits `0`.
90
+ - Hook is silent when no changed files or no blocking findings are detected.
91
+
92
+ ### Test 2.2: Blocking Finding
93
+
94
+ ```bash
95
+ cat > config.env <<'EOF'
96
+ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
97
+ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
98
+ EOF
99
+ git add config.env
100
+ git commit -m "Add secret"
101
+ .git/hooks/pre-push
102
+ echo $?
103
+ ```
104
+
105
+ Expected:
106
+
107
+ - Hook exits `1` when Gitleaks maps the finding to `high` or `critical`.
108
+ - Compact output starts with `Zwischen:` and lists severity, file, line, and message.
109
+ - Output includes the push-blocked guidance.
110
+
111
+ ### Test 2.3: Bypass Mechanisms
112
+
113
+ Expected:
114
+
115
+ - `git push --no-verify` bypasses Git hooks.
116
+ - `ZWISCHEN_SKIP=1 .git/hooks/pre-push` exits `0`.
117
+
118
+ ## Test Suite 3: Manual Scan Commands
119
+
120
+ ### Test 3.1: Standard Scan
121
+
122
+ ```bash
123
+ zwischen scan
124
+ echo $?
125
+ ```
126
+
127
+ Expected:
128
+
129
+ - Prints the scanning banner and full terminal report when findings exist.
130
+ - Exits `1` when findings meet the configured blocking severity.
131
+ - Exits `0` when no blocking findings exist.
132
+
133
+ ### Test 3.2: JSON Output
134
+
135
+ ```bash
136
+ zwischen scan --format json
137
+ ```
138
+
139
+ Expected:
140
+
141
+ - Prints valid JSON with `summary` and `findings`.
142
+ - Exit code still reflects configured blocking behavior.
143
+
144
+ ### Test 3.3: Scanner Selection
145
+
146
+ ```bash
147
+ zwischen scan --only secrets
148
+ zwischen scan --only sast
149
+ zwischen scan --only secrets,sast
150
+ ```
151
+
152
+ Expected:
153
+
154
+ - `secrets` selects Gitleaks.
155
+ - `sast` selects Semgrep.
156
+ - Missing scanners are skipped with a warning outside pre-push mode.
157
+
158
+ ### Test 3.4: Changed Files Filtering
159
+
160
+ ```bash
161
+ cat > changed.env <<'EOF'
162
+ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
163
+ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
164
+ EOF
165
+ git add changed.env
166
+ git commit -m "Add changed secret"
167
+
168
+ cat > uncommitted.env <<'EOF'
169
+ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
170
+ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
171
+ EOF
172
+ zwischen scan --pre-push
173
+ ```
174
+
175
+ Expected:
176
+
177
+ - Pre-push mode only reports findings from files returned by `GitDiff.changed_files`.
178
+ - Uncommitted files outside that diff are not reported.
179
+
180
+ ## Test Suite 4: Blocking Configuration
181
+
182
+ ### Test 4.1: Default Blocking
183
+
184
+ ```yaml
185
+ blocking:
186
+ severity: high
187
+ ```
188
+
189
+ Expected:
190
+
191
+ - `critical` and `high` findings block.
192
+ - `medium`, `low`, and `info` findings do not block.
193
+
194
+ ### Test 4.2: Critical Only
195
+
196
+ ```yaml
197
+ blocking:
198
+ severity: critical
199
+ ```
200
+
201
+ Expected:
202
+
203
+ - `critical` findings block.
204
+ - `high` findings do not block.
205
+
206
+ ### Test 4.3: No Blocking
207
+
208
+ ```yaml
209
+ blocking:
210
+ severity: none
211
+ ```
212
+
213
+ Expected:
214
+
215
+ - Findings may be reported.
216
+ - Scan exits `0`.
217
+ - Pre-push hook allows the push.
218
+
219
+ ## Test Suite 5: Uninstall
220
+
221
+ ### Test 5.1: Remove Zwischen Hook
222
+
223
+ ```bash
224
+ zwischen uninstall
225
+ # Answer y for hook removal.
226
+ # Answer n for config removal unless testing config deletion.
227
+ # Answer n for credentials removal unless testing credentials deletion.
228
+ ```
229
+
230
+ Expected:
231
+
232
+ - Zwischen hook is removed.
233
+ - `.zwischen.yml` is preserved when answering `n`.
234
+ - `~/.zwischen/credentials` is preserved when answering `n`.
235
+
236
+ ### Test 5.2: Preserve Non-Zwischen Hook
237
+
238
+ ```bash
239
+ printf "#!/bin/sh\nprintf 'custom hook\\n'\n" > .git/hooks/pre-push
240
+ chmod +x .git/hooks/pre-push
241
+ zwischen uninstall
242
+ ```
243
+
244
+ Expected:
245
+
246
+ - Custom hook remains because it does not contain the Zwischen marker.
247
+ - Uninstall reports that no Zwischen hook was found.
248
+
249
+ ## Test Suite 6: Edge Cases
250
+
251
+ ### Test 6.1: No Git Repository
252
+
253
+ ```bash
254
+ NO_GIT_DIR=$(mktemp -d -t zwischen-no-git-XXXXXX)
255
+ cd "$NO_GIT_DIR"
256
+ zwischen init
257
+ ```
258
+
259
+ Expected:
260
+
261
+ - Config is created.
262
+ - Hook installation is skipped with a warning.
263
+
264
+ ### Test 6.2: Existing Pre-Push Hook
265
+
266
+ ```bash
267
+ cd "$TEST_DIR/test-repo"
268
+ printf "#!/bin/sh\nprintf 'existing hook\\n'\n" > .git/hooks/pre-push
269
+ chmod +x .git/hooks/pre-push
270
+ zwischen init
271
+ ```
272
+
273
+ Expected for the current Ruby implementation:
274
+
275
+ - Existing hook is copied to `.git/hooks/pre-push.zwischen.backup` or a timestamped variant.
276
+ - New Zwischen hook replaces `.git/hooks/pre-push`.
277
+
278
+
279
+ ### Test 6.3: Default Branch Detection
280
+
281
+ Expected:
282
+
283
+ - Remote `origin` HEAD is preferred when available.
284
+ - Local `main` is used before local `master`.
285
+ - `HEAD` is the final fallback.
286
+
287
+ ## Test Suite 7: AI Integration
288
+
289
+ ### Test 7.1: Claude
290
+
291
+ ```bash
292
+ ANTHROPIC_API_KEY=... zwischen scan --ai claude
293
+ ```
294
+
295
+ Expected:
296
+
297
+ - AI analysis runs after scanner findings are aggregated.
298
+ - Findings may include fix suggestions and risk explanations.
299
+ - AI failures fall back to original findings without aborting the scan.
300
+
301
+ ### Test 7.2: Ollama
302
+
303
+ ```bash
304
+ ollama pull llama3
305
+ ollama serve
306
+ zwischen scan --ai ollama
307
+ ```
308
+
309
+ Expected:
310
+
311
+ - Ollama analysis runs against the configured local URL.
312
+ - If Ollama is not running, scan continues without AI and prints an AI warning outside pre-push mode.
313
+
314
+ ### Test 7.3: AI Disabled for Pre-Push
315
+
316
+ ```yaml
317
+ ai:
318
+ enabled: true
319
+ pre_push_enabled: false
320
+ ```
321
+
322
+ Expected:
323
+
324
+ - Manual `zwischen scan` can use AI.
325
+ - `zwischen scan --pre-push` does not use AI unless `pre_push_enabled` is `true`.
326
+
327
+ ## Report Template
328
+
329
+ ```markdown
330
+ # Zwischen Test Report
331
+
332
+ ## Environment
333
+ - Ruby version:
334
+ - Gem location:
335
+ - Test directory:
336
+ - Gitleaks version:
337
+ - Semgrep version:
338
+
339
+ ## Results
340
+ - Test 1.1:
341
+ - Test 1.2:
342
+ - Test 1.3:
343
+ - Test 2.1:
344
+ - Test 2.2:
345
+ - Test 2.3:
346
+ - Test 3.1:
347
+ - Test 3.2:
348
+ - Test 3.3:
349
+ - Test 3.4:
350
+ - Test 4.1:
351
+ - Test 4.2:
352
+ - Test 4.3:
353
+ - Test 5.1:
354
+ - Test 5.2:
355
+ - Test 6.1:
356
+ - Test 6.2:
357
+ - Test 6.3:
358
+ - Test 7.1:
359
+ - Test 7.2:
360
+ - Test 7.3:
361
+
362
+ ## Failures
363
+
364
+ ## Notes
365
+
366
+ ## Overall Status
367
+ ```
368
+
369
+ ## Cleanup
370
+
371
+ ```bash
372
+ gem uninstall zwischen --user-install
373
+ rm -rf /tmp/zwischen-test-* /tmp/zwischen-no-git-*
374
+ ```
data/bin/zwischen ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "zwischen"
5
+
6
+ # Always start CLI when this file is loaded (works for both direct execution and gem wrapper)
7
+ Zwischen::CLI.start(ARGV)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "anthropic_client"
4
+ require_relative "ollama_client"
5
+ require_relative "openai_client"
6
+ require_relative "../finding/finding"
7
+
8
+ module Zwischen
9
+ module AI
10
+ class Analyzer
11
+ def initialize(provider: "claude", api_key: nil, config: {}, project_context: {})
12
+ @project_context = project_context
13
+
14
+ client_class = case provider.to_s.downcase
15
+ when "claude", "anthropic" then AnthropicClient
16
+ when "ollama" then OllamaClient
17
+ when "openai" then OpenAIClient
18
+ else
19
+ # Fallback or error
20
+ AnthropicClient
21
+ end
22
+
23
+ @client = client_class.new(api_key: api_key, config: config)
24
+ end
25
+
26
+ def analyze(findings)
27
+ return findings if findings.empty?
28
+
29
+ prompt = build_prompt(findings)
30
+ response = @client.analyze(prompt)
31
+
32
+ enhance_findings(findings, response)
33
+ rescue AI::Error => e
34
+ warn "AI analysis failed: #{e.message}. Returning original findings."
35
+ findings
36
+ rescue StandardError => e
37
+ warn "AI analysis failed: #{e.message}. Returning original findings."
38
+ findings
39
+ end
40
+
41
+ private
42
+
43
+ def build_prompt(findings)
44
+ project_info = "Project type: #{@project_context[:primary_type] || 'unknown'}, Language: #{@project_context[:language] || 'unknown'}"
45
+
46
+ findings_text = findings.map.with_index(1) do |finding, idx|
47
+ <<~FINDING
48
+ #{idx}. [#{finding.severity.upcase}] #{finding.file}:#{finding.line}
49
+ Rule: #{finding.rule_id}
50
+ Message: #{finding.message}
51
+ #{finding.code_snippet ? " Code:\n #{finding.code_snippet.split("\n").map { |l| " #{l}" }.join("\n")}" : ""}
52
+ FINDING
53
+ end.join("\n")
54
+
55
+ <<~PROMPT
56
+ You are a senior security engineer reviewing security scan findings. Analyze the following findings and provide:
57
+
58
+ 1. Prioritization: Which findings are most critical and should be addressed first?
59
+ 2. False positives: Are any of these false positives that can be safely ignored?
60
+ 3. Fix suggestions: For each real finding, provide a clear, actionable fix suggestion.
61
+
62
+ #{project_info}
63
+
64
+ Findings:
65
+ #{findings_text}
66
+
67
+ Please respond in the following JSON format for each finding (by index number):
68
+ {
69
+ "1": {
70
+ "priority": "high|medium|low",
71
+ "is_false_positive": false,
72
+ "fix_suggestion": "Clear explanation of how to fix this issue",
73
+ "risk_explanation": "Why this is a security risk"
74
+ },
75
+ ...
76
+ }
77
+
78
+ If a finding is a false positive, set is_false_positive to true and explain why.
79
+ PROMPT
80
+ end
81
+
82
+ def enhance_findings(findings, ai_response)
83
+ # Try to parse JSON from the response
84
+ # Look for JSON object in the response
85
+ json_match = ai_response.match(/\{[\s\S]*\}/m)
86
+ return findings unless json_match
87
+
88
+ ai_analysis = JSON.parse(json_match[0])
89
+
90
+ findings.map.with_index(1) do |finding, idx|
91
+ analysis = ai_analysis[idx.to_s]
92
+ next finding unless analysis
93
+
94
+ # Add AI insights to raw_data
95
+ enhanced_data = finding.raw_data.merge(
96
+ "ai_priority" => analysis["priority"],
97
+ "ai_false_positive" => analysis["is_false_positive"] || false,
98
+ "ai_fix_suggestion" => analysis["fix_suggestion"],
99
+ "ai_risk_explanation" => analysis["risk_explanation"]
100
+ )
101
+
102
+ # Create new finding with enhanced data
103
+ Zwischen::Finding::Finding.new(
104
+ type: finding.type,
105
+ scanner: finding.scanner,
106
+ severity: finding.severity,
107
+ file: finding.file,
108
+ line: finding.line,
109
+ message: finding.message,
110
+ rule_id: finding.rule_id,
111
+ code_snippet: finding.code_snippet,
112
+ raw_data: enhanced_data
113
+ )
114
+ end
115
+ rescue JSON::ParserError => e
116
+ warn "Failed to parse AI response: #{e.message}"
117
+ findings
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "base_client"
6
+
7
+ module Zwischen
8
+ module AI
9
+ class AnthropicClient < BaseClient
10
+ API_BASE_URL = "https://api.anthropic.com/v1/"
11
+ API_VERSION = "2023-06-01"
12
+
13
+ def initialize(api_key: nil, config: {})
14
+ super
15
+ raise Error, "Claude API key not found." unless @api_key
16
+
17
+ @client = Faraday.new(url: API_BASE_URL) do |conn|
18
+ conn.request :json
19
+ conn.response :json, content_type: /\bjson$/
20
+ conn.adapter Faraday.default_adapter
21
+ end
22
+ end
23
+
24
+ def analyze(prompt)
25
+ model = @config["model"] || "claude-3-5-sonnet-20241022"
26
+
27
+ # Relative path: a leading slash would discard the /v1 prefix of the base URL
28
+ response = @client.post("messages") do |req|
29
+ req.headers["x-api-key"] = @api_key
30
+ req.headers["anthropic-version"] = API_VERSION
31
+ req.body = {
32
+ model: model,
33
+ max_tokens: 4096,
34
+ messages: [
35
+ {
36
+ role: "user",
37
+ content: prompt
38
+ }
39
+ ]
40
+ }
41
+ end
42
+
43
+ body = response.body
44
+ body = JSON.parse(body) if body.is_a?(String)
45
+
46
+ if response.success?
47
+ body.dig("content", 0, "text")
48
+ else
49
+ error_message = body.dig("error", "message") rescue body
50
+ raise Error, "Claude API error: #{error_message}"
51
+ end
52
+ rescue Faraday::Error => e
53
+ raise Error, "Network error: #{e.message}"
54
+ rescue JSON::ParserError => e
55
+ raise Error, "Invalid JSON response: #{e.message}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zwischen
4
+ module AI
5
+ class Error < StandardError; end
6
+
7
+ class BaseClient
8
+ attr_reader :api_key, :config
9
+
10
+ def initialize(api_key: nil, config: {})
11
+ @api_key = api_key
12
+ @config = config
13
+ validate_config!
14
+ end
15
+
16
+ def analyze(prompt)
17
+ raise NotImplementedError, "#{self.class.name} must implement #analyze"
18
+ end
19
+
20
+ protected
21
+
22
+ def validate_config!
23
+ # Hook for subclasses
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "base_client"
6
+
7
+ module Zwischen
8
+ module AI
9
+ class OllamaClient < BaseClient
10
+ def initialize(api_key: nil, config: {})
11
+ super
12
+ # Ollama usually doesn't need an API key, but we accept it if provided
13
+
14
+ base_url = @config["url"] || "http://localhost:11434"
15
+ # Ensure base URL doesn't end with /api/chat if user provided full path
16
+ base_url = base_url.sub(/\/api\/chat\/?$/, "")
17
+
18
+ @client = Faraday.new(url: base_url) do |conn|
19
+ conn.request :json
20
+ conn.response :json, content_type: /\bjson$/
21
+ # Local models can take a while to load and generate; default 60s
22
+ # is too tight for larger models.
23
+ conn.options.timeout = (@config["timeout"] || 180).to_i
24
+ conn.adapter Faraday.default_adapter
25
+ end
26
+ end
27
+
28
+ def analyze(prompt)
29
+ model = @config["model"] || "llama3"
30
+
31
+ response = @client.post("/api/chat") do |req|
32
+ req.body = {
33
+ model: model,
34
+ messages: [
35
+ {
36
+ role: "user",
37
+ content: prompt
38
+ }
39
+ ],
40
+ stream: false
41
+ }
42
+ end
43
+
44
+ if response.success?
45
+ content = response.body.dig("message", "content")
46
+ unless content
47
+ raise Error, "Unexpected Ollama response format: #{response.body}"
48
+ end
49
+ content
50
+ else
51
+ error_message = response.body.dig("error") || "Unknown error"
52
+ raise Error, "Ollama API error: #{error_message}"
53
+ end
54
+ rescue Faraday::Error => e
55
+ raise Error, "Ollama connection error: #{e.message}. Is Ollama running?"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "base_client"
6
+
7
+ module Zwischen
8
+ module AI
9
+ class OpenAIClient < BaseClient
10
+ API_BASE_URL = "https://api.openai.com/v1/"
11
+
12
+ def initialize(api_key: nil, config: {})
13
+ super
14
+ raise Error, "OpenAI API key not found." unless @api_key
15
+
16
+ @client = Faraday.new(url: API_BASE_URL) do |conn|
17
+ conn.request :json
18
+ conn.response :json, content_type: /\bjson$/
19
+ conn.adapter Faraday.default_adapter
20
+ end
21
+ end
22
+
23
+ def analyze(prompt)
24
+ model = @config["model"] || "gpt-4"
25
+
26
+ # Relative path: a leading slash would discard the /v1 prefix of the base URL
27
+ response = @client.post("chat/completions") do |req|
28
+ req.headers["Authorization"] = "Bearer #{@api_key}"
29
+ req.body = {
30
+ model: model,
31
+ messages: [
32
+ {
33
+ role: "user",
34
+ content: prompt
35
+ }
36
+ ]
37
+ }
38
+ end
39
+
40
+ body = response.body
41
+ body = JSON.parse(body) if body.is_a?(String)
42
+
43
+ if response.success?
44
+ body.dig("choices", 0, "message", "content")
45
+ else
46
+ error_message = body.dig("error", "message") rescue body
47
+ raise Error, "OpenAI API error: #{error_message}"
48
+ end
49
+ rescue Faraday::Error => e
50
+ raise Error, "Network error: #{e.message}"
51
+ rescue JSON::ParserError => e
52
+ raise Error, "Invalid JSON response: #{e.message}"
53
+ end
54
+ end
55
+ end
56
+ end