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