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/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,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
|