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 +22 -0
- zwischen/ai.py +180 -0
- zwischen/cli.py +39 -0
- zwischen/config.py +103 -0
- zwischen/detector.py +191 -0
- zwischen/doctor.py +63 -0
- zwischen/init.py +72 -0
- zwischen/installer.py +122 -0
- zwischen/scanner.py +256 -0
- zwischen_cli-0.1.0.dist-info/METADATA +108 -0
- zwischen_cli-0.1.0.dist-info/RECORD +14 -0
- zwischen_cli-0.1.0.dist-info/WHEEL +5 -0
- zwischen_cli-0.1.0.dist-info/entry_points.txt +2 -0
- zwischen_cli-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
zwischen
|