zwischen-cli 0.1.0__py3-none-any.whl

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.
zwischen/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Zwischen - AI-augmented security scanning for vibe coders."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .scanner import scan, run_gitleaks, run_semgrep
6
+ from .installer import install_gitleaks, get_gitleaks_path, is_gitleaks_installed
7
+ from .config import load_config, create_config
8
+ from .ai import analyze_with_ai
9
+ from .detector import detect_project
10
+
11
+ __all__ = [
12
+ "scan",
13
+ "run_gitleaks",
14
+ "run_semgrep",
15
+ "install_gitleaks",
16
+ "get_gitleaks_path",
17
+ "is_gitleaks_installed",
18
+ "load_config",
19
+ "create_config",
20
+ "analyze_with_ai",
21
+ "detect_project",
22
+ ]
zwischen/ai.py ADDED
@@ -0,0 +1,180 @@
1
+ """AI provider clients for Zwischen."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+
11
+ def _build_prompt(findings: list[dict]) -> str:
12
+ """Build AI analysis prompt."""
13
+ def format_finding(i: int, f: dict) -> str:
14
+ code_line = f" Code: {f['code_snippet']}" if f.get('code_snippet') else ""
15
+ return (
16
+ f"{i + 1}. [{(f.get('severity') or 'medium').upper()}] {f['file']}:{f['line']}\n"
17
+ f" Rule: {f.get('rule_id', 'unknown')}\n"
18
+ f" Message: {f.get('message', '')}\n"
19
+ f"{code_line}"
20
+ )
21
+
22
+ findings_text = "\n\n".join(format_finding(i, f) for i, f in enumerate(findings))
23
+
24
+ return f"""You are a senior security engineer reviewing security scan findings. Analyze the following findings and provide:
25
+
26
+ 1. Prioritization: Which findings are most critical and should be addressed first?
27
+ 2. False positives: Are any of these false positives that can be safely ignored?
28
+ 3. Fix suggestions: For each real finding, provide a clear, actionable fix suggestion.
29
+
30
+ Findings:
31
+ {findings_text}
32
+
33
+ Please respond in the following JSON format for each finding (by index number):
34
+ {{
35
+ "1": {{
36
+ "priority": "high|medium|low",
37
+ "is_false_positive": false,
38
+ "fix_suggestion": "Clear explanation of how to fix this issue",
39
+ "risk_explanation": "Why this is a security risk"
40
+ }}
41
+ }}
42
+
43
+ If a finding is a false positive, set is_false_positive to true and explain why."""
44
+
45
+
46
+ def _call_ollama(prompt: str, config: dict) -> str:
47
+ """Call Ollama API."""
48
+ base_url = config.get("url", "http://localhost:11434")
49
+ model = config.get("model", "llama3")
50
+
51
+ response = requests.post(
52
+ f"{base_url}/api/chat",
53
+ json={
54
+ "model": model,
55
+ "messages": [{"role": "user", "content": prompt}],
56
+ "stream": False,
57
+ },
58
+ timeout=120,
59
+ )
60
+
61
+ if response.status_code != 200:
62
+ raise Exception(f"Ollama error: {response.text}")
63
+
64
+ data = response.json()
65
+ if "error" in data:
66
+ raise Exception(f"Ollama error: {data['error']}")
67
+
68
+ return data.get("message", {}).get("content", "")
69
+
70
+
71
+ def _call_openai(prompt: str, config: dict) -> str:
72
+ """Call OpenAI API."""
73
+ api_key = config.get("api_key") or os.environ.get("OPENAI_API_KEY")
74
+ if not api_key:
75
+ raise Exception("OpenAI API key not found. Set OPENAI_API_KEY or provide --api-key")
76
+
77
+ model = config.get("model", "gpt-4o-mini")
78
+
79
+ response = requests.post(
80
+ "https://api.openai.com/v1/chat/completions",
81
+ headers={
82
+ "Authorization": f"Bearer {api_key}",
83
+ "Content-Type": "application/json",
84
+ },
85
+ json={
86
+ "model": model,
87
+ "messages": [{"role": "user", "content": prompt}],
88
+ },
89
+ timeout=120,
90
+ )
91
+
92
+ if response.status_code != 200:
93
+ raise Exception(f"OpenAI error: {response.text}")
94
+
95
+ data = response.json()
96
+ if "error" in data:
97
+ raise Exception(f"OpenAI error: {data['error']['message']}")
98
+
99
+ return data.get("choices", [{}])[0].get("message", {}).get("content", "")
100
+
101
+
102
+ def _call_anthropic(prompt: str, config: dict) -> str:
103
+ """Call Anthropic API."""
104
+ api_key = config.get("api_key") or os.environ.get("ANTHROPIC_API_KEY")
105
+ if not api_key:
106
+ raise Exception("Anthropic API key not found. Set ANTHROPIC_API_KEY or provide --api-key")
107
+
108
+ model = config.get("model", "claude-3-haiku-20240307")
109
+
110
+ response = requests.post(
111
+ "https://api.anthropic.com/v1/messages",
112
+ headers={
113
+ "x-api-key": api_key,
114
+ "anthropic-version": "2023-06-01",
115
+ "Content-Type": "application/json",
116
+ },
117
+ json={
118
+ "model": model,
119
+ "max_tokens": 4096,
120
+ "messages": [{"role": "user", "content": prompt}],
121
+ },
122
+ timeout=120,
123
+ )
124
+
125
+ if response.status_code != 200:
126
+ raise Exception(f"Anthropic error: {response.text}")
127
+
128
+ data = response.json()
129
+ if "error" in data:
130
+ raise Exception(f"Anthropic error: {data['error']['message']}")
131
+
132
+ return data.get("content", [{}])[0].get("text", "")
133
+
134
+
135
+ def analyze_with_ai(
136
+ findings: list[dict],
137
+ provider: str = "ollama",
138
+ api_key: str | None = None,
139
+ **config,
140
+ ) -> list[dict]:
141
+ """Analyze findings with AI."""
142
+ if not findings:
143
+ return findings
144
+
145
+ prompt = _build_prompt(findings)
146
+ config["api_key"] = api_key
147
+
148
+ provider = provider.lower()
149
+ if provider == "ollama":
150
+ response = _call_ollama(prompt, config)
151
+ elif provider == "openai":
152
+ response = _call_openai(prompt, config)
153
+ elif provider in ("anthropic", "claude"):
154
+ response = _call_anthropic(prompt, config)
155
+ else:
156
+ raise Exception(f"Unknown AI provider: {provider}")
157
+
158
+ # Parse AI response
159
+ try:
160
+ json_match = re.search(r"\{[\s\S]*\}", response)
161
+ if not json_match:
162
+ return findings
163
+
164
+ analysis = json.loads(json_match.group())
165
+
166
+ return [
167
+ {
168
+ **f,
169
+ "ai_priority": analysis.get(str(i + 1), {}).get("priority"),
170
+ "ai_false_positive": analysis.get(str(i + 1), {}).get("is_false_positive", False),
171
+ "ai_fix_suggestion": analysis.get(str(i + 1), {}).get("fix_suggestion"),
172
+ "ai_risk_explanation": analysis.get(str(i + 1), {}).get("risk_explanation"),
173
+ }
174
+ for i, f in enumerate(findings)
175
+ ]
176
+
177
+ except (json.JSONDecodeError, Exception) as e:
178
+ if os.environ.get("DEBUG"):
179
+ print(f"Failed to parse AI response: {e}")
180
+ return findings
zwischen/cli.py ADDED
@@ -0,0 +1,39 @@
1
+ """Command-line interface for Zwischen."""
2
+
3
+ import click
4
+ from .scanner import scan as do_scan
5
+ from .init import init as do_init
6
+ from .doctor import doctor as do_doctor
7
+
8
+
9
+ @click.group()
10
+ @click.version_option()
11
+ def main():
12
+ """AI-augmented security scanning for vibe coders."""
13
+ pass
14
+
15
+
16
+ @main.command()
17
+ def init():
18
+ """Initialize Zwischen in your project."""
19
+ do_init()
20
+
21
+
22
+ @main.command()
23
+ @click.option("--ai", help="AI provider (ollama, openai, anthropic)")
24
+ @click.option("--api-key", help="API key for AI provider")
25
+ @click.option("--format", "output_format", default="terminal", help="Output format (terminal, json)")
26
+ @click.option("--pre-push", is_flag=True, help="Pre-push mode (compact output)")
27
+ def scan(ai, api_key, output_format, pre_push):
28
+ """Run security scan."""
29
+ do_scan(ai=ai, api_key=api_key, output_format=output_format, pre_push=pre_push)
30
+
31
+
32
+ @main.command()
33
+ def doctor():
34
+ """Check if required tools are installed."""
35
+ do_doctor()
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
zwischen/config.py ADDED
@@ -0,0 +1,103 @@
1
+ """Configuration handling for Zwischen."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+ import yaml
6
+
7
+ DEFAULT_CONFIG = {
8
+ "ai": {
9
+ "enabled": True,
10
+ "pre_push_enabled": False,
11
+ "provider": "ollama",
12
+ "model": "llama3",
13
+ },
14
+ "blocking": {
15
+ "severity": "high",
16
+ },
17
+ "scanners": {
18
+ "gitleaks": {"enabled": True},
19
+ "semgrep": {"enabled": True, "config": "p/security-audit"},
20
+ },
21
+ "ignore": [
22
+ "**/node_modules/**",
23
+ "**/vendor/**",
24
+ "**/.git/**",
25
+ "**/dist/**",
26
+ "**/build/**",
27
+ "**/test/fixtures/**",
28
+ ],
29
+ }
30
+
31
+ EXAMPLE_CONFIG = """# Zwischen Configuration
32
+
33
+ # AI Provider Configuration
34
+ ai:
35
+ enabled: true
36
+ pre_push_enabled: false # Disable AI in pre-push hooks (performance)
37
+ provider: ollama # Options: ollama, openai, anthropic
38
+ model: llama3 # Model name for your provider
39
+ # url: http://localhost:11434 # For Ollama (default)
40
+ # api_key: null # For OpenAI/Anthropic (or use env vars)
41
+
42
+ # What blocks a push
43
+ blocking:
44
+ severity: high # block on high or critical (default)
45
+ # severity: critical # only block on critical
46
+ # severity: none # never block, just warn
47
+
48
+ # Scanner Configuration
49
+ scanners:
50
+ gitleaks: true # Auto-installed if missing
51
+ semgrep: true # Optional, install with: pip install semgrep
52
+
53
+ # Ignored Paths (glob patterns)
54
+ ignore:
55
+ - "**/node_modules/**"
56
+ - "**/vendor/**"
57
+ - "**/.git/**"
58
+ - "**/dist/**"
59
+ - "**/build/**"
60
+ """
61
+
62
+
63
+ def deep_merge(base: dict, override: dict) -> dict:
64
+ """Deep merge two dictionaries."""
65
+ result = base.copy()
66
+ for key, value in override.items():
67
+ if (
68
+ key in result
69
+ and isinstance(result[key], dict)
70
+ and isinstance(value, dict)
71
+ ):
72
+ result[key] = deep_merge(result[key], value)
73
+ else:
74
+ result[key] = value
75
+ return result
76
+
77
+
78
+ def load_config(project_root: str | Path = ".") -> dict:
79
+ """Load configuration from .zwischen.yml."""
80
+ config_path = Path(project_root) / ".zwischen.yml"
81
+
82
+ if not config_path.exists():
83
+ return DEFAULT_CONFIG.copy()
84
+
85
+ try:
86
+ with open(config_path) as f:
87
+ user_config = yaml.safe_load(f) or {}
88
+ return deep_merge(DEFAULT_CONFIG, user_config)
89
+ except Exception as e:
90
+ print(f"Warning: Could not parse .zwischen.yml: {e}")
91
+ return DEFAULT_CONFIG.copy()
92
+
93
+
94
+ def create_config(project_root: str | Path = ".") -> bool:
95
+ """Create configuration file."""
96
+ config_path = Path(project_root) / ".zwischen.yml"
97
+
98
+ if config_path.exists():
99
+ return False
100
+
101
+ with open(config_path, "w") as f:
102
+ f.write(EXAMPLE_CONFIG)
103
+ return True
zwischen/detector.py ADDED
@@ -0,0 +1,191 @@
1
+ """Project and framework detection for Zwischen."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ # Base detection patterns
8
+ DETECTION_PATTERNS = {
9
+ "node": ["package.json"],
10
+ "python": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "poetry.lock"],
11
+ "ruby": ["Gemfile", "Rakefile"],
12
+ "go": ["go.mod", "go.sum"],
13
+ "java": ["pom.xml", "build.gradle", "build.gradle.kts"],
14
+ "rust": ["Cargo.toml", "Cargo.lock"],
15
+ "php": ["composer.json"],
16
+ "dotnet": ["*.csproj", "*.sln", "*.fsproj"],
17
+ }
18
+
19
+ # JS framework detection
20
+ JS_FRAMEWORKS = {
21
+ "nextjs": ["next"],
22
+ "react": ["react"],
23
+ "vue": ["vue"],
24
+ "angular": ["@angular/core"],
25
+ "svelte": ["svelte"],
26
+ "express": ["express"],
27
+ "nestjs": ["@nestjs/core"],
28
+ "nuxt": ["nuxt"],
29
+ "remix": ["@remix-run/react"],
30
+ "astro": ["astro"],
31
+ "gatsby": ["gatsby"],
32
+ }
33
+
34
+ # Python framework detection
35
+ PYTHON_FRAMEWORKS = {
36
+ "django": ["django"],
37
+ "fastapi": ["fastapi"],
38
+ "flask": ["flask"],
39
+ "pyramid": ["pyramid"],
40
+ "tornado": ["tornado"],
41
+ "starlette": ["starlette"],
42
+ "streamlit": ["streamlit"],
43
+ "jupyter": ["jupyter", "jupyterlab", "notebook"],
44
+ }
45
+
46
+ # Ruby framework detection
47
+ RUBY_FRAMEWORKS = {
48
+ "rails": ["rails"],
49
+ "sinatra": ["sinatra"],
50
+ "hanami": ["hanami"],
51
+ "grape": ["grape"],
52
+ "roda": ["roda"],
53
+ }
54
+
55
+ # Framework to language mapping
56
+ FRAMEWORK_LANGUAGES = {
57
+ "nextjs": "javascript", "react": "javascript", "vue": "javascript",
58
+ "angular": "typescript", "svelte": "javascript", "express": "javascript",
59
+ "nestjs": "typescript", "nuxt": "javascript", "remix": "javascript",
60
+ "astro": "javascript", "gatsby": "javascript",
61
+ "django": "python", "fastapi": "python", "flask": "python",
62
+ "pyramid": "python", "tornado": "python", "starlette": "python",
63
+ "streamlit": "python", "jupyter": "python",
64
+ "rails": "ruby", "sinatra": "ruby", "hanami": "ruby",
65
+ "grape": "ruby", "roda": "ruby",
66
+ }
67
+
68
+
69
+ def detect_project(project_root: str | Path = ".") -> dict[str, Any]:
70
+ """Detect project type and frameworks."""
71
+ project_root = Path(project_root)
72
+
73
+ types = _detect_base_types(project_root)
74
+ frameworks = _detect_frameworks(project_root)
75
+
76
+ primary = frameworks[0] if frameworks else (types[0] if types else None)
77
+ language = (
78
+ FRAMEWORK_LANGUAGES.get(frameworks[0], types[0] if types else "unknown")
79
+ if frameworks
80
+ else (types[0] if types else "unknown")
81
+ )
82
+
83
+ return {
84
+ "types": types,
85
+ "primary_type": primary,
86
+ "language": language,
87
+ "frameworks": frameworks,
88
+ "root": str(project_root),
89
+ }
90
+
91
+
92
+ def _detect_base_types(project_root: Path) -> list[str]:
93
+ """Detect base project types."""
94
+ detected = []
95
+
96
+ for type_name, patterns in DETECTION_PATTERNS.items():
97
+ if any(_matches_pattern(project_root, p) for p in patterns):
98
+ detected.append(type_name)
99
+
100
+ return detected
101
+
102
+
103
+ def _matches_pattern(project_root: Path, pattern: str) -> bool:
104
+ """Check if pattern matches any file."""
105
+ if "*" in pattern:
106
+ return bool(list(project_root.glob(pattern)))
107
+ return (project_root / pattern).exists()
108
+
109
+
110
+ def _detect_frameworks(project_root: Path) -> list[str]:
111
+ """Detect frameworks."""
112
+ frameworks = []
113
+ frameworks.extend(_detect_js_frameworks(project_root))
114
+ frameworks.extend(_detect_python_frameworks(project_root))
115
+ frameworks.extend(_detect_ruby_frameworks(project_root))
116
+ return list(dict.fromkeys(frameworks)) # Unique, preserve order
117
+
118
+
119
+ def _detect_js_frameworks(project_root: Path) -> list[str]:
120
+ """Detect JS frameworks from package.json."""
121
+ package_json = project_root / "package.json"
122
+ if not package_json.exists():
123
+ return []
124
+
125
+ try:
126
+ with open(package_json) as f:
127
+ pkg = json.load(f)
128
+
129
+ all_deps = list(pkg.get("dependencies", {}).keys()) + list(
130
+ pkg.get("devDependencies", {}).keys()
131
+ )
132
+
133
+ detected = []
134
+ for framework, packages in JS_FRAMEWORKS.items():
135
+ if any(p in all_deps for p in packages):
136
+ detected.append(framework)
137
+
138
+ # Sort by specificity
139
+ priority = [
140
+ "nextjs", "nuxt", "remix", "gatsby", "astro",
141
+ "angular", "nestjs", "svelte", "vue", "react", "express"
142
+ ]
143
+ return sorted(detected, key=lambda x: priority.index(x) if x in priority else 999)
144
+
145
+ except (json.JSONDecodeError, OSError):
146
+ return []
147
+
148
+
149
+ def _detect_python_frameworks(project_root: Path) -> list[str]:
150
+ """Detect Python frameworks."""
151
+ frameworks = []
152
+ files = ["requirements.txt", "pyproject.toml", "Pipfile"]
153
+
154
+ for filename in files:
155
+ filepath = project_root / filename
156
+ if filepath.exists():
157
+ try:
158
+ content = filepath.read_text().lower()
159
+ for framework, packages in PYTHON_FRAMEWORKS.items():
160
+ if any(p.lower() in content for p in packages):
161
+ frameworks.append(framework)
162
+ except OSError:
163
+ pass
164
+
165
+ priority = [
166
+ "django", "fastapi", "flask", "pyramid",
167
+ "tornado", "starlette", "streamlit", "jupyter"
168
+ ]
169
+ unique = list(dict.fromkeys(frameworks))
170
+ return sorted(unique, key=lambda x: priority.index(x) if x in priority else 999)
171
+
172
+
173
+ def _detect_ruby_frameworks(project_root: Path) -> list[str]:
174
+ """Detect Ruby frameworks from Gemfile."""
175
+ gemfile = project_root / "Gemfile"
176
+ if not gemfile.exists():
177
+ return []
178
+
179
+ try:
180
+ content = gemfile.read_text().lower()
181
+ detected = []
182
+
183
+ for framework, gems in RUBY_FRAMEWORKS.items():
184
+ if any(f"gem '{g}'" in content or f'gem "{g}"' in content for g in gems):
185
+ detected.append(framework)
186
+
187
+ priority = ["rails", "hanami", "sinatra", "grape", "roda"]
188
+ return sorted(detected, key=lambda x: priority.index(x) if x in priority else 999)
189
+
190
+ except OSError:
191
+ return []
zwischen/doctor.py ADDED
@@ -0,0 +1,63 @@
1
+ """Doctor command for Zwischen."""
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from .installer import get_gitleaks_path, BIN_DIR
7
+
8
+
9
+ def doctor() -> None:
10
+ """Check if required tools are installed."""
11
+ print("\n" + "=" * 60)
12
+ print("Zwischen Doctor - Tool Status")
13
+ print("=" * 60 + "\n")
14
+
15
+ all_installed = True
16
+
17
+ tools = [
18
+ {
19
+ "name": "gitleaks",
20
+ "description": "Secrets detection",
21
+ "check": get_gitleaks_path,
22
+ "install": "Auto-installed by zwischen init",
23
+ },
24
+ {
25
+ "name": "semgrep",
26
+ "description": "Static analysis (optional)",
27
+ "check": lambda: shutil.which("semgrep"),
28
+ "install": "pip install semgrep",
29
+ },
30
+ ]
31
+
32
+ for tool in tools:
33
+ tool_path = tool["check"]()
34
+
35
+ if tool_path:
36
+ version = ""
37
+ try:
38
+ result = subprocess.run(
39
+ [tool_path, "--version"],
40
+ capture_output=True,
41
+ text=True,
42
+ )
43
+ version = result.stdout.strip().split("\n")[0]
44
+ except Exception:
45
+ pass
46
+
47
+ print(f"\033[32m✓ {tool['name']}\033[0m - {tool['description']}")
48
+ if version:
49
+ print(f" Version: {version}")
50
+ if tool_path.startswith(str(BIN_DIR)):
51
+ print(f" Location: {tool_path}")
52
+ else:
53
+ if "optional" not in tool["description"]:
54
+ all_installed = False
55
+ print(f"\033[31m✗ {tool['name']}\033[0m - {tool['description']} - NOT FOUND")
56
+ print(f" → {tool['install']}")
57
+
58
+ print()
59
+
60
+ if all_installed:
61
+ print("\033[32m✅ All required tools are installed!\033[0m\n")
62
+ else:
63
+ print('\033[33m⚠️ Some tools are missing. Run "zwischen init" to install them.\033[0m\n')
zwischen/init.py ADDED
@@ -0,0 +1,72 @@
1
+ """Initialization for Zwischen."""
2
+
3
+ import os
4
+ import shutil
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ from .installer import install_gitleaks, is_gitleaks_installed
9
+ from .config import create_config
10
+
11
+ PRE_PUSH_HOOK = """#!/bin/sh
12
+ # Zwischen pre-push hook
13
+ # Runs security scan on changed files before push
14
+
15
+ zwischen scan --pre-push
16
+ """
17
+
18
+
19
+ def init() -> None:
20
+ """Initialize Zwischen in project."""
21
+ project_root = Path.cwd()
22
+
23
+ print("\n🛡️ Initializing Zwischen...\n")
24
+
25
+ # 1. Install gitleaks if needed
26
+ if not is_gitleaks_installed():
27
+ print(" Installing gitleaks...")
28
+ if not install_gitleaks():
29
+ print(" ⚠️ Could not auto-install gitleaks")
30
+ else:
31
+ print(" ✓ gitleaks already installed")
32
+
33
+ # 2. Check for semgrep (optional)
34
+ if shutil.which("semgrep"):
35
+ print(" ✓ semgrep available")
36
+ else:
37
+ print(" ↳ semgrep not found (optional)")
38
+ print(" → pip install semgrep")
39
+
40
+ # 3. Create config file
41
+ if create_config(project_root):
42
+ print(" ✓ Created .zwischen.yml")
43
+ else:
44
+ print(" ✓ Config already exists")
45
+
46
+ # 4. Install git hook
47
+ git_dir = project_root / ".git"
48
+ if git_dir.exists():
49
+ hooks_dir = git_dir / "hooks"
50
+ hooks_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ hook_path = hooks_dir / "pre-push"
53
+
54
+ if hook_path.exists():
55
+ content = hook_path.read_text()
56
+ if "zwischen" not in content:
57
+ # Append to existing hook
58
+ with open(hook_path, "a") as f:
59
+ f.write("\n" + PRE_PUSH_HOOK)
60
+ print(" ✓ Added to existing pre-push hook")
61
+ else:
62
+ print(" ✓ Pre-push hook already configured")
63
+ else:
64
+ hook_path.write_text(PRE_PUSH_HOOK)
65
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
66
+ print(" ✓ Installed pre-push hook")
67
+ else:
68
+ print(" ↳ Not a git repository, skipping hook installation")
69
+
70
+ print("\n✅ Zwischen initialized!\n")
71
+ print('Run "zwischen scan" to scan your project.')
72
+ print("Security checks will run automatically before each push.\n")
zwischen/installer.py ADDED
@@ -0,0 +1,122 @@
1
+ """Gitleaks installer for Zwischen."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import stat
7
+ import tarfile
8
+ import tempfile
9
+ from pathlib import Path
10
+ from urllib.request import urlopen, Request
11
+ import json
12
+
13
+ ZWISCHEN_DIR = Path.home() / ".zwischen"
14
+ BIN_DIR = ZWISCHEN_DIR / "bin"
15
+ GITLEAKS_REPO = "gitleaks/gitleaks"
16
+
17
+ PLATFORMS = {
18
+ "Darwin": "darwin",
19
+ "Linux": "linux",
20
+ "Windows": "windows",
21
+ }
22
+
23
+ ARCHS = {
24
+ "x86_64": "x64",
25
+ "AMD64": "x64",
26
+ "aarch64": "arm64",
27
+ "arm64": "arm64",
28
+ }
29
+
30
+
31
+ def fetch_json(url: str) -> dict:
32
+ """Fetch JSON from URL."""
33
+ req = Request(url, headers={"User-Agent": "zwischen"})
34
+ with urlopen(req) as response:
35
+ return json.loads(response.read().decode())
36
+
37
+
38
+ def download_file(url: str, dest: Path) -> None:
39
+ """Download file from URL."""
40
+ req = Request(url, headers={"User-Agent": "zwischen"})
41
+ with urlopen(req) as response:
42
+ with open(dest, "wb") as f:
43
+ shutil.copyfileobj(response, f)
44
+
45
+
46
+ def install_gitleaks() -> bool:
47
+ """Install gitleaks binary."""
48
+ plat = PLATFORMS.get(platform.system())
49
+ arch = ARCHS.get(platform.machine(), "x64")
50
+
51
+ if not plat:
52
+ print(f"Unsupported platform: {platform.system()}")
53
+ return False
54
+
55
+ # Ensure directories exist
56
+ BIN_DIR.mkdir(parents=True, exist_ok=True)
57
+
58
+ gitleaks_name = "gitleaks.exe" if plat == "windows" else "gitleaks"
59
+ gitleaks_path = BIN_DIR / gitleaks_name
60
+
61
+ # Check if already installed
62
+ if gitleaks_path.exists():
63
+ return True
64
+
65
+ print(" Downloading gitleaks...")
66
+
67
+ try:
68
+ # Get latest release
69
+ release = fetch_json(f"https://api.github.com/repos/{GITLEAKS_REPO}/releases/latest")
70
+
71
+ # Find matching asset
72
+ pattern = f"gitleaks_"
73
+ suffix = f"_{plat}_{arch}.tar.gz"
74
+ asset = next(
75
+ (a for a in release["assets"] if a["name"].startswith(pattern) and a["name"].endswith(suffix)),
76
+ None,
77
+ )
78
+
79
+ if not asset:
80
+ print(f"No gitleaks binary found for {plat}_{arch}")
81
+ return False
82
+
83
+ # Download and extract
84
+ with tempfile.TemporaryDirectory() as tmpdir:
85
+ tarball_path = Path(tmpdir) / "gitleaks.tar.gz"
86
+ download_file(asset["browser_download_url"], tarball_path)
87
+
88
+ with tarfile.open(tarball_path, "r:gz") as tar:
89
+ for member in tar.getmembers():
90
+ if member.name == "gitleaks":
91
+ member.name = gitleaks_name
92
+ tar.extract(member, BIN_DIR)
93
+ break
94
+
95
+ # Make executable
96
+ if plat != "windows":
97
+ gitleaks_path.chmod(gitleaks_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
98
+
99
+ print(" ✓ Installed gitleaks")
100
+ return True
101
+
102
+ except Exception as e:
103
+ print(f" ✗ Failed to install gitleaks: {e}")
104
+ return False
105
+
106
+
107
+ def get_gitleaks_path() -> str | None:
108
+ """Get path to gitleaks executable."""
109
+ gitleaks_name = "gitleaks.exe" if platform.system() == "Windows" else "gitleaks"
110
+ local_path = BIN_DIR / gitleaks_name
111
+
112
+ if local_path.exists():
113
+ return str(local_path)
114
+
115
+ # Check system PATH
116
+ system_path = shutil.which("gitleaks")
117
+ return system_path
118
+
119
+
120
+ def is_gitleaks_installed() -> bool:
121
+ """Check if gitleaks is installed."""
122
+ return get_gitleaks_path() is not None
zwischen/scanner.py ADDED
@@ -0,0 +1,256 @@
1
+ """Security scanners for Zwischen."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .installer import get_gitleaks_path
13
+ from .config import load_config
14
+ from .ai import analyze_with_ai
15
+ from .detector import detect_project
16
+
17
+
18
+ def run_gitleaks(project_root: str = ".", files: list[str] | None = None) -> list[dict]:
19
+ """Run gitleaks scanner."""
20
+ gitleaks_path = get_gitleaks_path()
21
+ if not gitleaks_path:
22
+ return []
23
+
24
+ findings = []
25
+
26
+ try:
27
+ if files:
28
+ # Scan specific files
29
+ for file in files:
30
+ file_path = Path(project_root) / file
31
+ if not file_path.exists():
32
+ continue
33
+
34
+ result = subprocess.run(
35
+ [
36
+ gitleaks_path,
37
+ "detect",
38
+ "--source", str(file_path),
39
+ "--report-format", "json",
40
+ "--report-path", "-",
41
+ "--no-git",
42
+ ],
43
+ capture_output=True,
44
+ text=True,
45
+ cwd=project_root,
46
+ )
47
+
48
+ if result.stdout:
49
+ try:
50
+ parsed = json.loads(result.stdout)
51
+ findings.extend(parsed if isinstance(parsed, list) else [])
52
+ except json.JSONDecodeError:
53
+ pass
54
+ else:
55
+ # Scan entire project
56
+ result = subprocess.run(
57
+ [
58
+ gitleaks_path,
59
+ "detect",
60
+ "--source", project_root,
61
+ "--report-format", "json",
62
+ "--report-path", "-",
63
+ "--no-git",
64
+ ],
65
+ capture_output=True,
66
+ text=True,
67
+ cwd=project_root,
68
+ )
69
+
70
+ if result.stdout:
71
+ try:
72
+ parsed = json.loads(result.stdout)
73
+ findings.extend(parsed if isinstance(parsed, list) else [])
74
+ except json.JSONDecodeError:
75
+ pass
76
+
77
+ except Exception as e:
78
+ if os.environ.get("DEBUG"):
79
+ print(f"Gitleaks error: {e}", file=sys.stderr)
80
+
81
+ return [
82
+ {
83
+ "type": "secret",
84
+ "scanner": "gitleaks",
85
+ "severity": _map_gitleaks_severity(f.get("RuleID", "")),
86
+ "file": f.get("File", ""),
87
+ "line": f.get("StartLine", 0),
88
+ "message": f.get("RuleID", "Secret detected"),
89
+ "rule_id": f.get("RuleID", ""),
90
+ "code_snippet": f.get("Secret", ""),
91
+ "raw": f,
92
+ }
93
+ for f in findings
94
+ ]
95
+
96
+
97
+ def _map_gitleaks_severity(rule_id: str) -> str:
98
+ """Map gitleaks rule to severity."""
99
+ rule_id = rule_id.lower()
100
+ if re.search(r"aws.*key|api.*key|private.*key|secret.*key", rule_id):
101
+ return "critical"
102
+ if re.search(r"password|token|credential", rule_id):
103
+ return "high"
104
+ return "medium"
105
+
106
+
107
+ def run_semgrep(project_root: str = ".", files: list[str] | None = None) -> list[dict]:
108
+ """Run semgrep scanner."""
109
+ if not shutil.which("semgrep"):
110
+ return []
111
+
112
+ findings = []
113
+
114
+ try:
115
+ args = ["semgrep", "--json", "--config", "p/security-audit"]
116
+ if files:
117
+ args.extend(files)
118
+ else:
119
+ args.append(project_root)
120
+
121
+ result = subprocess.run(
122
+ args,
123
+ capture_output=True,
124
+ text=True,
125
+ cwd=project_root,
126
+ )
127
+
128
+ if result.stdout:
129
+ try:
130
+ parsed = json.loads(result.stdout)
131
+ for r in parsed.get("results", []):
132
+ findings.append({
133
+ "type": "vulnerability",
134
+ "scanner": "semgrep",
135
+ "severity": r.get("extra", {}).get("severity", "medium"),
136
+ "file": r.get("path", ""),
137
+ "line": r.get("start", {}).get("line", 0),
138
+ "message": r.get("extra", {}).get("message", r.get("check_id", "")),
139
+ "rule_id": r.get("check_id", ""),
140
+ "code_snippet": r.get("extra", {}).get("lines", ""),
141
+ "raw": r,
142
+ })
143
+ except json.JSONDecodeError:
144
+ pass
145
+
146
+ except Exception as e:
147
+ if os.environ.get("DEBUG"):
148
+ print(f"Semgrep error: {e}", file=sys.stderr)
149
+
150
+ return findings
151
+
152
+
153
+ def scan(
154
+ ai: str | None = None,
155
+ api_key: str | None = None,
156
+ output_format: str = "terminal",
157
+ pre_push: bool = False,
158
+ ) -> None:
159
+ """Run security scan."""
160
+ project_root = os.getcwd()
161
+ config = load_config(project_root)
162
+ project = detect_project(project_root)
163
+
164
+ if not pre_push:
165
+ framework_info = (
166
+ f"{project['frameworks'][0]} ({project['language']})"
167
+ if project['frameworks']
168
+ else project['primary_type'] or 'project'
169
+ )
170
+ print(f"\n🔍 Scanning {framework_info}...\n")
171
+
172
+ # Run scanners
173
+ gitleaks_findings = run_gitleaks(project_root)
174
+ semgrep_findings = run_semgrep(project_root)
175
+ findings = gitleaks_findings + semgrep_findings
176
+
177
+ if not findings:
178
+ if not pre_push:
179
+ print("✅ No security issues found!\n")
180
+ sys.exit(0)
181
+
182
+ # AI analysis if requested
183
+ if ai:
184
+ if not pre_push:
185
+ print(f"🤖 Analyzing with AI ({ai})...\n")
186
+ try:
187
+ findings = analyze_with_ai(
188
+ findings,
189
+ provider=ai,
190
+ api_key=api_key or config.get("ai", {}).get("api_key"),
191
+ )
192
+ except Exception as e:
193
+ if not pre_push:
194
+ print(f"⚠️ AI analysis unavailable: {e}")
195
+
196
+ # Report findings
197
+ if output_format == "json":
198
+ print(json.dumps({"findings": findings}, indent=2))
199
+ else:
200
+ _report_findings(findings, pre_push)
201
+
202
+ # Exit with error if blocking findings
203
+ blocking_severity = config.get("blocking", {}).get("severity", "high")
204
+ has_blocking = any(_should_block(f, blocking_severity) for f in findings)
205
+ sys.exit(1 if has_blocking else 0)
206
+
207
+
208
+ def _should_block(finding: dict, blocking_severity: str) -> bool:
209
+ """Check if finding should block."""
210
+ if finding.get("ai_false_positive"):
211
+ return False
212
+
213
+ severity = (finding.get("severity") or "medium").lower()
214
+ if blocking_severity == "critical":
215
+ return severity == "critical"
216
+ if blocking_severity == "high":
217
+ return severity in ("critical", "high")
218
+ if blocking_severity == "none":
219
+ return False
220
+ return severity in ("critical", "high")
221
+
222
+
223
+ def _report_findings(findings: list[dict], compact: bool = False) -> None:
224
+ """Report findings to terminal."""
225
+ by_severity = {"critical": [], "high": [], "medium": [], "low": []}
226
+
227
+ for f in findings:
228
+ sev = (f.get("severity") or "medium").lower()
229
+ if sev in by_severity:
230
+ by_severity[sev].append(f)
231
+ else:
232
+ by_severity["medium"].append(f)
233
+
234
+ print("🛡️ Security Scan Results\n")
235
+ print(f"Found {len(findings)} issue(s):\n")
236
+
237
+ colors = {
238
+ "critical": "\033[31m", # red
239
+ "high": "\033[33m", # yellow
240
+ "medium": "\033[36m", # cyan
241
+ "low": "\033[37m", # white
242
+ }
243
+ reset = "\033[0m"
244
+
245
+ for severity, items in by_severity.items():
246
+ if not items:
247
+ continue
248
+
249
+ print(f"{colors[severity]}{severity.upper()} ({len(items)}){reset}")
250
+
251
+ for f in items:
252
+ fp = " [FALSE POSITIVE]" if f.get("ai_false_positive") else ""
253
+ print(f" {f['file']}:{f['line']} - {f['message']}{fp}")
254
+ if f.get("ai_fix_suggestion") and not compact:
255
+ print(f" 💡 {f['ai_fix_suggestion']}")
256
+ print()
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: zwischen-cli
3
+ Version: 0.1.0
4
+ Summary: AI-augmented security scanning for vibe coders. Zero-config secrets detection and vulnerability scanning.
5
+ Author-email: Conner Jordan <connercharlesjordan@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cjordan223/zwischen
8
+ Project-URL: Repository, https://github.com/cjordan223/zwischen.git
9
+ Project-URL: Issues, https://github.com/cjordan223/Zwischen/issues
10
+ Keywords: security,scanner,secrets,gitleaks,semgrep,ai,vulnerability,sast
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: click>=8.0.0
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: requests>=2.28.0
27
+
28
+ # Zwischen Python Package
29
+
30
+ Python wrapper for Zwischen, an AI-augmented security scanning CLI. This package exposes a Python implementation of the core workflow for Python users.
31
+
32
+ The Ruby gem in the repository root is currently the canonical implementation. This wrapper has a smaller command surface and may not match every Ruby feature.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install zwischen-cli
38
+ ```
39
+
40
+ The PyPI distribution is named `zwischen-cli` (the bare `zwischen` name is taken by an unrelated project), but the installed command is still `zwischen`.
41
+
42
+ For local development:
43
+
44
+ ```bash
45
+ cd packages/pip
46
+ python -m pip install -e .
47
+ zwischen --help
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ ```bash
53
+ zwischen init
54
+ zwischen scan
55
+ zwischen scan --ai ollama
56
+ zwischen scan --ai openai --api-key "$OPENAI_API_KEY"
57
+ zwischen scan --format json
58
+ zwischen scan --pre-push
59
+ zwischen doctor
60
+ ```
61
+
62
+ Supported scan flags:
63
+
64
+ - `--ai`: `ollama`, `openai`, or `anthropic`
65
+ - `--api-key`: provider API key
66
+ - `--format`: `terminal` or `json`
67
+ - `--pre-push`: compact hook mode
68
+
69
+ Not currently supported in this wrapper:
70
+
71
+ - `zwischen uninstall`
72
+ - `zwischen scan --only ...`
73
+ - Ruby's changed-file filtering for `--pre-push`
74
+
75
+ ## Behavior
76
+
77
+ `zwischen init` tries to install Gitleaks into `~/.zwischen/bin`, creates `.zwischen.yml`, checks whether Semgrep is available, and installs or appends a Git `pre-push` hook when run inside a Git repository.
78
+
79
+ Semgrep is optional:
80
+
81
+ ```bash
82
+ pip install semgrep
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ The Python wrapper creates this shape:
88
+
89
+ ```yaml
90
+ ai:
91
+ enabled: true
92
+ pre_push_enabled: false
93
+ provider: ollama
94
+ model: llama3
95
+
96
+ blocking:
97
+ severity: high
98
+
99
+ scanners:
100
+ gitleaks: true
101
+ semgrep: true
102
+ ```
103
+
104
+ Blocking severities are `high`, `critical`, or `none`.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,14 @@
1
+ zwischen/__init__.py,sha256=N6G1NuwIh46p0j4idknMZiJDyuwQBQKWqqrHSs0Zad4,569
2
+ zwischen/ai.py,sha256=5-H3ZI1c9ot9gfEFOOuAPVfH9ivXLam_rLg3sIMvyNE,5628
3
+ zwischen/cli.py,sha256=c4v6LG83eBoc255OaOVpivWlxf8nZZ9pkpt1scJRPQk,1007
4
+ zwischen/config.py,sha256=h4dfWcjv0r3tCQULEc331BauZI-2vNUpXG0t7N9Scdg,2721
5
+ zwischen/detector.py,sha256=gGoWAk_AbZTq43SY84NYOo-a3-4AShhy9mmqrX7Y_b8,5999
6
+ zwischen/doctor.py,sha256=y_52789eOj_aTzWU18_1OKMNxPChtz4vDxqPjymu8HI,1882
7
+ zwischen/init.py,sha256=rAiiLmyXO3-gDwOZCybYOIo2AWnhWcXCNtC_SZZeT1Q,2224
8
+ zwischen/installer.py,sha256=fjFmsM4DZl2Gx8qWyNN42BjREqPBHpKvNjgfiAQ01WU,3391
9
+ zwischen/scanner.py,sha256=0d7xGqnS0845V4RQV-tWYR52o5UyqXQ1hfVx3723_HY,7985
10
+ zwischen_cli-0.1.0.dist-info/METADATA,sha256=ikLON5_loDFQ-UMuM6z4shjs5GyDnijvujZCM3CaR2E,2920
11
+ zwischen_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ zwischen_cli-0.1.0.dist-info/entry_points.txt,sha256=3o6AOoPmXjKI0kU9Fe1FQMB8-tckSGe-TN8PrsiA6F0,47
13
+ zwischen_cli-0.1.0.dist-info/top_level.txt,sha256=kR82MS5v9Jb4vxpDB0CbGycvBXRvLTQgeLIMmJJVnPk,9
14
+ zwischen_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zwischen = zwischen.cli:main
@@ -0,0 +1 @@
1
+ zwischen