caruso 0.5.4 → 0.6.2
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 +4 -4
- data/.rubocop.yml +6 -1
- data/AGENTS.md +277 -0
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +1 -0
- data/README.md +13 -10
- data/SECURITY.md +98 -0
- data/caruso.gemspec +1 -1
- data/caruso.json +5 -0
- data/lib/caruso/adapter.rb +4 -1
- data/lib/caruso/cli.rb +53 -59
- data/lib/caruso/config_manager.rb +19 -0
- data/lib/caruso/fetcher.rb +51 -14
- data/lib/caruso/marketplace_registry.rb +3 -3
- data/lib/caruso/path_sanitizer.rb +59 -0
- data/lib/caruso/remover.rb +60 -0
- data/lib/caruso/safe_dir.rb +54 -0
- data/lib/caruso/safe_file.rb +39 -0
- data/lib/caruso/version.rb +1 -1
- data/lib/caruso.rb +3 -0
- metadata +9 -2
- data/CLAUDE.md +0 -276
data/lib/caruso/cli.rb
CHANGED
|
@@ -20,8 +20,10 @@ module Caruso
|
|
|
20
20
|
fetcher = Caruso::Fetcher.new(url, ref: options[:ref])
|
|
21
21
|
|
|
22
22
|
# For Git repos, clone/update the cache (skip in test mode to allow fake URLs)
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
# Fixed ReDoS: Use anchored regex and limit input length to prevent catastrophic backtracking
|
|
24
|
+
is_owner_repo = url.length < 256 && url.match?(%r{\A[^/]+/[^/]+\z})
|
|
25
|
+
if (source == "github" || url.match?(/\Ahttps?:/) || is_owner_repo) && !ENV["CARUSO_TESTING_SKIP_CLONE"]
|
|
26
|
+
fetcher.clone_git_repo({ "url" => url, "source" => source })
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# Read marketplace name from marketplace.json
|
|
@@ -49,27 +51,30 @@ module Caruso
|
|
|
49
51
|
end
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
desc "remove
|
|
53
|
-
def remove(
|
|
54
|
+
desc "remove NAME_OR_URL", "Remove a marketplace"
|
|
55
|
+
def remove(name_or_url)
|
|
54
56
|
config_manager = load_config
|
|
57
|
+
marketplaces = config_manager.list_marketplaces
|
|
55
58
|
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
puts "Cache directory still exists at: #{cache_dir}"
|
|
69
|
-
puts "Run 'rm -rf #{cache_dir}' to delete it if desired."
|
|
59
|
+
# Try to find by name first
|
|
60
|
+
if marketplaces.key?(name_or_url)
|
|
61
|
+
name = name_or_url
|
|
62
|
+
else
|
|
63
|
+
# Try to find by URL
|
|
64
|
+
# We need to check exact match or maybe normalized match
|
|
65
|
+
match = marketplaces.find { |_, details| details["url"] == name_or_url || details["url"].chomp(".git") == name_or_url }
|
|
66
|
+
if match
|
|
67
|
+
name = match[0]
|
|
68
|
+
else
|
|
69
|
+
puts "Marketplace '#{name_or_url}' not found."
|
|
70
|
+
return
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
# Use Remover to handle cleanup
|
|
75
|
+
remover = Caruso::Remover.new(config_manager)
|
|
76
|
+
remover.remove_marketplace(name)
|
|
77
|
+
|
|
73
78
|
puts "Removed marketplace '#{name}'"
|
|
74
79
|
end
|
|
75
80
|
|
|
@@ -86,14 +91,14 @@ module Caruso
|
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
puts "Marketplace: #{name}"
|
|
89
|
-
puts " Source: #{marketplace['source']}" if marketplace[
|
|
94
|
+
puts " Source: #{marketplace['source']}" if marketplace["source"]
|
|
90
95
|
puts " URL: #{marketplace['url']}"
|
|
91
96
|
puts " Location: #{marketplace['install_location']}"
|
|
92
97
|
puts " Last Updated: #{marketplace['last_updated']}"
|
|
93
|
-
puts " Ref: #{marketplace['ref']}" if marketplace[
|
|
98
|
+
puts " Ref: #{marketplace['ref']}" if marketplace["ref"]
|
|
94
99
|
|
|
95
100
|
# Check if directory actually exists
|
|
96
|
-
if Dir.exist?(marketplace[
|
|
101
|
+
if Dir.exist?(marketplace["install_location"])
|
|
97
102
|
puts " Status: ✓ Cached locally"
|
|
98
103
|
else
|
|
99
104
|
puts " Status: ✗ Cache directory missing"
|
|
@@ -105,12 +110,12 @@ module Caruso
|
|
|
105
110
|
config_manager = load_config
|
|
106
111
|
marketplaces = config_manager.list_marketplaces
|
|
107
112
|
|
|
113
|
+
if marketplaces.empty?
|
|
114
|
+
puts "No marketplaces configured. Use 'caruso marketplace add <url>' to get started."
|
|
115
|
+
return
|
|
116
|
+
end
|
|
108
117
|
if name
|
|
109
118
|
# Update specific marketplace
|
|
110
|
-
if marketplaces.empty?
|
|
111
|
-
puts "No marketplaces configured. Use 'caruso marketplace add <url>' to get started."
|
|
112
|
-
return
|
|
113
|
-
end
|
|
114
119
|
|
|
115
120
|
marketplace_details = config_manager.get_marketplace_details(name)
|
|
116
121
|
unless marketplace_details
|
|
@@ -121,7 +126,8 @@ module Caruso
|
|
|
121
126
|
|
|
122
127
|
puts "Updating marketplace '#{name}'..."
|
|
123
128
|
begin
|
|
124
|
-
fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: name,
|
|
129
|
+
fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: name,
|
|
130
|
+
ref: marketplace_details["ref"])
|
|
125
131
|
fetcher.update_cache
|
|
126
132
|
puts "Updated marketplace '#{name}'"
|
|
127
133
|
rescue StandardError => e
|
|
@@ -129,25 +135,19 @@ module Caruso
|
|
|
129
135
|
end
|
|
130
136
|
else
|
|
131
137
|
# Update all marketplaces
|
|
132
|
-
if marketplaces.empty?
|
|
133
|
-
puts "No marketplaces configured. Use 'caruso marketplace add <url>' to get started."
|
|
134
|
-
return
|
|
135
|
-
end
|
|
136
138
|
|
|
137
139
|
puts "Updating all marketplaces..."
|
|
138
140
|
success_count = 0
|
|
139
141
|
error_count = 0
|
|
140
142
|
|
|
141
143
|
marketplaces.each do |marketplace_name, details|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
error_count += 1
|
|
150
|
-
end
|
|
144
|
+
puts " Updating #{marketplace_name}..."
|
|
145
|
+
fetcher = Caruso::Fetcher.new(details["url"], marketplace_name: marketplace_name, ref: details["ref"])
|
|
146
|
+
fetcher.update_cache
|
|
147
|
+
success_count += 1
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
puts " Error updating #{marketplace_name}: #{e.message}"
|
|
150
|
+
error_count += 1
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
puts "\nUpdated #{success_count} marketplace(s)" + (error_count.positive? ? " (#{error_count} failed)" : "")
|
|
@@ -263,15 +263,9 @@ module Caruso
|
|
|
263
263
|
end
|
|
264
264
|
|
|
265
265
|
puts "Removing #{plugin_key}..."
|
|
266
|
-
files_to_remove = config_manager.remove_plugin(plugin_key)
|
|
267
266
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if File.exist?(full_path)
|
|
271
|
-
File.delete(full_path)
|
|
272
|
-
puts " Deleted #{file}"
|
|
273
|
-
end
|
|
274
|
-
end
|
|
267
|
+
remover = Caruso::Remover.new(config_manager)
|
|
268
|
+
remover.remove_plugin(plugin_key)
|
|
275
269
|
|
|
276
270
|
puts "Uninstalled #{plugin_key}."
|
|
277
271
|
end
|
|
@@ -323,14 +317,12 @@ module Caruso
|
|
|
323
317
|
error_count = 0
|
|
324
318
|
|
|
325
319
|
installed_plugins.each do |key, plugin_data|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
error_count += 1
|
|
333
|
-
end
|
|
320
|
+
puts " Updating #{key}..."
|
|
321
|
+
update_single_plugin(key, plugin_data, config_manager)
|
|
322
|
+
success_count += 1
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
puts " Error updating #{key}: #{e.message}"
|
|
325
|
+
error_count += 1
|
|
334
326
|
end
|
|
335
327
|
|
|
336
328
|
puts "\nUpdated #{success_count} plugin(s)" + (error_count.positive? ? " (#{error_count} failed)" : "")
|
|
@@ -377,7 +369,7 @@ module Caruso
|
|
|
377
369
|
desc "outdated", "Show plugins with available updates"
|
|
378
370
|
def outdated
|
|
379
371
|
config_manager = load_config
|
|
380
|
-
|
|
372
|
+
config_manager.full_target_path
|
|
381
373
|
|
|
382
374
|
installed_plugins = config_manager.list_plugins
|
|
383
375
|
|
|
@@ -389,7 +381,7 @@ module Caruso
|
|
|
389
381
|
puts "Checking for updates..."
|
|
390
382
|
outdated_plugins = []
|
|
391
383
|
|
|
392
|
-
|
|
384
|
+
config_manager.list_marketplaces
|
|
393
385
|
|
|
394
386
|
installed_plugins.each do |key, plugin_data|
|
|
395
387
|
marketplace_name = plugin_data["marketplace"]
|
|
@@ -399,7 +391,8 @@ module Caruso
|
|
|
399
391
|
next unless marketplace_details
|
|
400
392
|
|
|
401
393
|
begin
|
|
402
|
-
|
|
394
|
+
Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name,
|
|
395
|
+
ref: marketplace_details["ref"])
|
|
403
396
|
# For now, we'll just report that updates might be available
|
|
404
397
|
# Full version comparison would require version tracking in marketplace.json
|
|
405
398
|
outdated_plugins << {
|
|
@@ -438,7 +431,8 @@ module Caruso
|
|
|
438
431
|
end
|
|
439
432
|
|
|
440
433
|
# Update marketplace cache first
|
|
441
|
-
fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name,
|
|
434
|
+
fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name,
|
|
435
|
+
ref: marketplace_details["ref"])
|
|
442
436
|
fetcher.update_cache
|
|
443
437
|
|
|
444
438
|
# Parse plugin name from key (plugin@marketplace)
|
|
@@ -150,6 +150,23 @@ module Caruso
|
|
|
150
150
|
save_project_config(data)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
def remove_marketplace_with_plugins(marketplace_name)
|
|
154
|
+
files_to_remove = []
|
|
155
|
+
|
|
156
|
+
# Find and remove all plugins associated with this marketplace
|
|
157
|
+
installed_plugins = list_plugins
|
|
158
|
+
installed_plugins.each do |plugin_key, details|
|
|
159
|
+
if details["marketplace"] == marketplace_name
|
|
160
|
+
files_to_remove.concat(remove_plugin(plugin_key))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Remove the marketplace itself
|
|
165
|
+
remove_marketplace(marketplace_name)
|
|
166
|
+
|
|
167
|
+
files_to_remove.uniq
|
|
168
|
+
end
|
|
169
|
+
|
|
153
170
|
def list_marketplaces
|
|
154
171
|
load_project_config["marketplaces"] || {}
|
|
155
172
|
end
|
|
@@ -167,6 +184,7 @@ module Caruso
|
|
|
167
184
|
|
|
168
185
|
def load_project_config
|
|
169
186
|
return {} unless File.exist?(@project_config_path)
|
|
187
|
+
|
|
170
188
|
JSON.parse(File.read(@project_config_path))
|
|
171
189
|
rescue JSON::ParserError
|
|
172
190
|
{}
|
|
@@ -178,6 +196,7 @@ module Caruso
|
|
|
178
196
|
|
|
179
197
|
def load_local_config
|
|
180
198
|
return {} unless File.exist?(@local_config_path)
|
|
199
|
+
|
|
181
200
|
JSON.parse(File.read(@local_config_path))
|
|
182
201
|
rescue JSON::ParserError
|
|
183
202
|
{}
|
data/lib/caruso/fetcher.rb
CHANGED
|
@@ -5,6 +5,9 @@ require "faraday"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "uri"
|
|
7
7
|
require "git"
|
|
8
|
+
require_relative "safe_file"
|
|
9
|
+
require_relative "safe_dir"
|
|
10
|
+
require_relative "path_sanitizer"
|
|
8
11
|
|
|
9
12
|
module Caruso
|
|
10
13
|
class Fetcher
|
|
@@ -49,7 +52,7 @@ module Caruso
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def update_cache
|
|
52
|
-
return unless
|
|
55
|
+
return unless SafeDir.exist?(cache_dir)
|
|
53
56
|
|
|
54
57
|
Dir.chdir(cache_dir) do
|
|
55
58
|
git = Git.open(".")
|
|
@@ -61,7 +64,7 @@ module Caruso
|
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
@registry.update_timestamp(@marketplace_name)
|
|
64
|
-
rescue => e
|
|
67
|
+
rescue StandardError => e
|
|
65
68
|
handle_git_error(e)
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -69,10 +72,10 @@ module Caruso
|
|
|
69
72
|
url = source_config["url"] || source_config["repo"]
|
|
70
73
|
url = "https://github.com/#{url}.git" if source_config["source"] == "github" && !url.match?(/\Ahttps?:/)
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
URI.parse(url).path.split("/").last.sub(".git", "")
|
|
73
76
|
target_path = cache_dir
|
|
74
77
|
|
|
75
|
-
unless
|
|
78
|
+
unless SafeDir.exist?(target_path)
|
|
76
79
|
# Clone the repository
|
|
77
80
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
78
81
|
Git.clone(url, target_path)
|
|
@@ -105,7 +108,7 @@ module Caruso
|
|
|
105
108
|
def load_marketplace
|
|
106
109
|
if local_path?
|
|
107
110
|
# If marketplace_uri is a directory, find marketplace.json in it
|
|
108
|
-
if
|
|
111
|
+
if SafeDir.exist?(@marketplace_uri)
|
|
109
112
|
json_path = File.join(@marketplace_uri, ".claude-plugin", "marketplace.json")
|
|
110
113
|
json_path = File.join(@marketplace_uri, "marketplace.json") unless File.exist?(json_path)
|
|
111
114
|
|
|
@@ -122,7 +125,7 @@ module Caruso
|
|
|
122
125
|
@base_dir = File.dirname(@base_dir)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
|
-
JSON.parse(
|
|
128
|
+
JSON.parse(SafeFile.read(@marketplace_uri))
|
|
126
129
|
elsif github_repo?
|
|
127
130
|
# Clone repo and read marketplace.json from it
|
|
128
131
|
repo_path = clone_git_repo("url" => @marketplace_uri, "source" => "github")
|
|
@@ -138,7 +141,7 @@ module Caruso
|
|
|
138
141
|
@marketplace_uri = json_path
|
|
139
142
|
@base_dir = repo_path # Base dir is the repo root, regardless of where json is
|
|
140
143
|
|
|
141
|
-
JSON.parse(
|
|
144
|
+
JSON.parse(SafeFile.read(json_path))
|
|
142
145
|
else
|
|
143
146
|
response = Faraday.get(@marketplace_uri)
|
|
144
147
|
JSON.parse(response.body)
|
|
@@ -146,6 +149,10 @@ module Caruso
|
|
|
146
149
|
end
|
|
147
150
|
|
|
148
151
|
def github_repo?
|
|
152
|
+
# Fixed ReDoS: Add length check to prevent catastrophic backtracking
|
|
153
|
+
# GitHub URLs and owner/repo format should be reasonably short
|
|
154
|
+
return false if @marketplace_uri.length > 512
|
|
155
|
+
|
|
149
156
|
@marketplace_uri.match?(%r{\Ahttps://github\.com/[^/]+/[^/]+}) ||
|
|
150
157
|
@marketplace_uri.match?(%r{\A[^/]+/[^/]+\z}) # owner/repo format
|
|
151
158
|
end
|
|
@@ -153,7 +160,25 @@ module Caruso
|
|
|
153
160
|
def fetch_plugin(plugin)
|
|
154
161
|
source = plugin["source"]
|
|
155
162
|
plugin_path = resolve_plugin_path(source)
|
|
156
|
-
return [] unless plugin_path
|
|
163
|
+
return [] unless plugin_path
|
|
164
|
+
|
|
165
|
+
# Validate that plugin_path is safe before using it
|
|
166
|
+
# resolve_plugin_path returns paths from trusted sources:
|
|
167
|
+
# - cache_dir (under ~/.caruso/marketplaces/)
|
|
168
|
+
# - validated local paths relative to @base_dir
|
|
169
|
+
# However, we still validate to ensure no path traversal
|
|
170
|
+
begin
|
|
171
|
+
# For paths under ~/.caruso, validate against home directory
|
|
172
|
+
# For local paths, they're already validated in resolve_plugin_path
|
|
173
|
+
if plugin_path.start_with?(File.join(Dir.home, ".caruso"))
|
|
174
|
+
PathSanitizer.sanitize_path(plugin_path, base_dir: File.join(Dir.home, ".caruso"))
|
|
175
|
+
end
|
|
176
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
177
|
+
warn "Invalid plugin path '#{plugin_path}': #{e.message}"
|
|
178
|
+
return []
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return [] unless SafeDir.exist?(plugin_path)
|
|
157
182
|
|
|
158
183
|
# Start with default directories
|
|
159
184
|
files = find_steering_files(plugin_path)
|
|
@@ -186,7 +211,12 @@ module Caruso
|
|
|
186
211
|
end
|
|
187
212
|
|
|
188
213
|
def find_steering_files(plugin_path)
|
|
189
|
-
|
|
214
|
+
# Validate plugin_path before using it in glob
|
|
215
|
+
# This is safe because plugin_path comes from resolve_plugin_path which returns trusted paths
|
|
216
|
+
# (either from cache_dir which is under ~/.caruso, or validated local paths)
|
|
217
|
+
glob_pattern = PathSanitizer.safe_join(plugin_path, "{commands,agents,skills}", "**", "*.md")
|
|
218
|
+
|
|
219
|
+
SafeDir.glob(glob_pattern, base_dir: plugin_path).reject do |file|
|
|
190
220
|
basename = File.basename(file).downcase
|
|
191
221
|
["readme.md", "license.md"].include?(basename)
|
|
192
222
|
end
|
|
@@ -199,16 +229,23 @@ module Caruso
|
|
|
199
229
|
|
|
200
230
|
files = []
|
|
201
231
|
paths.each do |path|
|
|
202
|
-
# Resolve the path relative to plugin_path
|
|
203
|
-
|
|
232
|
+
# Resolve and sanitize the path relative to plugin_path
|
|
233
|
+
# This ensures the path stays within plugin_path boundaries
|
|
234
|
+
begin
|
|
235
|
+
full_path = PathSanitizer.sanitize_path(File.expand_path(path, plugin_path), base_dir: plugin_path)
|
|
236
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
237
|
+
warn "Skipping path outside plugin directory '#{path}': #{e.message}"
|
|
238
|
+
next
|
|
239
|
+
end
|
|
204
240
|
|
|
205
241
|
# Handle both files and directories
|
|
206
242
|
if File.file?(full_path) && full_path.end_with?(".md")
|
|
207
243
|
basename = File.basename(full_path).downcase
|
|
208
244
|
files << full_path unless ["readme.md", "license.md"].include?(basename)
|
|
209
|
-
elsif
|
|
210
|
-
# Find all .md files in this directory
|
|
211
|
-
|
|
245
|
+
elsif SafeDir.exist?(full_path, base_dir: plugin_path)
|
|
246
|
+
# Find all .md files in this directory using safe_join
|
|
247
|
+
glob_pattern = PathSanitizer.safe_join(full_path, "**", "*.md")
|
|
248
|
+
SafeDir.glob(glob_pattern, base_dir: plugin_path).each do |file|
|
|
212
249
|
basename = File.basename(file).downcase
|
|
213
250
|
files << file unless ["readme.md", "license.md"].include?(basename)
|
|
214
251
|
end
|
|
@@ -15,7 +15,7 @@ module Caruso
|
|
|
15
15
|
def add_marketplace(name, url, install_location, ref: nil, source: "git")
|
|
16
16
|
data = load_registry
|
|
17
17
|
data[name] = {
|
|
18
|
-
"source" => source,
|
|
18
|
+
"source" => source, # github, git, url, local, directory
|
|
19
19
|
"url" => url,
|
|
20
20
|
"install_location" => install_location,
|
|
21
21
|
"last_updated" => Time.now.iso8601,
|
|
@@ -51,7 +51,7 @@ module Caruso
|
|
|
51
51
|
|
|
52
52
|
# Enhancement #3: Schema validation
|
|
53
53
|
def validate_marketplace_entry(entry)
|
|
54
|
-
required = [
|
|
54
|
+
required = %w[url install_location last_updated]
|
|
55
55
|
missing = required - entry.keys
|
|
56
56
|
|
|
57
57
|
unless missing.empty?
|
|
@@ -71,7 +71,7 @@ module Caruso
|
|
|
71
71
|
data = JSON.parse(File.read(@registry_path))
|
|
72
72
|
|
|
73
73
|
# Validate each entry
|
|
74
|
-
data.
|
|
74
|
+
data.each_value do |entry|
|
|
75
75
|
validate_marketplace_entry(entry)
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
# PathSanitizer provides utilities to validate and sanitize file paths
|
|
7
|
+
# to prevent path traversal attacks and ensure paths stay within expected boundaries
|
|
8
|
+
module PathSanitizer
|
|
9
|
+
class PathTraversalError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Validates that a path doesn't contain path traversal sequences
|
|
12
|
+
# and stays within the expected base directory
|
|
13
|
+
#
|
|
14
|
+
# @param path [String] The path to validate
|
|
15
|
+
# @param base_dir [String] Optional base directory to validate against
|
|
16
|
+
# @return [String] The validated, normalized path
|
|
17
|
+
# @raise [PathTraversalError] if path contains traversal sequences or escapes base_dir
|
|
18
|
+
def self.sanitize_path(path, base_dir: nil)
|
|
19
|
+
return nil if path.nil? || path.empty?
|
|
20
|
+
|
|
21
|
+
# Normalize the path to resolve any . or .. components
|
|
22
|
+
normalized_path = Pathname.new(path).expand_path
|
|
23
|
+
|
|
24
|
+
# If base_dir is provided, ensure the path stays within it
|
|
25
|
+
if base_dir
|
|
26
|
+
normalized_base = Pathname.new(base_dir).expand_path
|
|
27
|
+
|
|
28
|
+
# Check if path is within base using relative_path_from
|
|
29
|
+
# This will raise ArgumentError if path is not within base
|
|
30
|
+
begin
|
|
31
|
+
relative = normalized_path.relative_path_from(normalized_base)
|
|
32
|
+
# Check that the relative path doesn't start with ..
|
|
33
|
+
if relative.to_s.start_with?("..")
|
|
34
|
+
raise PathTraversalError, "Path escapes base directory: #{path}"
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
# Paths on different drives/volumes
|
|
38
|
+
raise PathTraversalError, "Path is not within base directory: #{path}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
normalized_path.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Safely joins path components, ensuring no traversal attacks
|
|
46
|
+
#
|
|
47
|
+
# @param base [String] The base directory
|
|
48
|
+
# @param *parts [String] Path components to join
|
|
49
|
+
# @return [String] The sanitized joined path
|
|
50
|
+
# @raise [PathTraversalError] if result would escape base directory
|
|
51
|
+
def self.safe_join(base, *parts)
|
|
52
|
+
# Join the parts
|
|
53
|
+
joined = File.join(base, *parts)
|
|
54
|
+
|
|
55
|
+
# Validate the result stays within base
|
|
56
|
+
sanitize_path(joined, base_dir: base)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "marketplace_registry"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
class Remover
|
|
7
|
+
attr_reader :config_manager
|
|
8
|
+
|
|
9
|
+
def initialize(config_manager)
|
|
10
|
+
@config_manager = config_manager
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def remove_marketplace(name)
|
|
14
|
+
# 1. Remove from config and get associated plugin files
|
|
15
|
+
# This updates both project config (plugins) and local config (files)
|
|
16
|
+
files_to_remove = config_manager.remove_marketplace_with_plugins(name)
|
|
17
|
+
|
|
18
|
+
# 2. Delete the actual files
|
|
19
|
+
delete_files(files_to_remove)
|
|
20
|
+
|
|
21
|
+
# 3. Clean up registry cache
|
|
22
|
+
remove_from_registry(name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def remove_plugin(name)
|
|
26
|
+
# 1. Remove from config
|
|
27
|
+
files_to_remove = config_manager.remove_plugin(name)
|
|
28
|
+
|
|
29
|
+
# 2. Delete files
|
|
30
|
+
delete_files(files_to_remove)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def delete_files(files)
|
|
36
|
+
files.each do |file|
|
|
37
|
+
full_path = File.join(config_manager.project_dir, file)
|
|
38
|
+
if File.exist?(full_path)
|
|
39
|
+
File.delete(full_path)
|
|
40
|
+
puts " Deleted #{file}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def remove_from_registry(name)
|
|
46
|
+
registry = Caruso::MarketplaceRegistry.new
|
|
47
|
+
marketplace = registry.get_marketplace(name)
|
|
48
|
+
return unless marketplace
|
|
49
|
+
|
|
50
|
+
cache_dir = marketplace["install_location"]
|
|
51
|
+
registry.remove_marketplace(name)
|
|
52
|
+
|
|
53
|
+
# Inform about cache directory
|
|
54
|
+
return unless Dir.exist?(cache_dir)
|
|
55
|
+
|
|
56
|
+
puts "Cache directory still exists at: #{cache_dir}"
|
|
57
|
+
puts "Run 'rm -rf #{cache_dir}' to delete it if desired."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_sanitizer"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
class SafeDir
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class SecurityError < Error; end
|
|
9
|
+
|
|
10
|
+
# Safely checks if a directory exists
|
|
11
|
+
#
|
|
12
|
+
# @param path [String] The path to check
|
|
13
|
+
# @param base_dir [String] Optional base directory to restrict access to
|
|
14
|
+
# @return [Boolean] true if directory exists and is safe
|
|
15
|
+
def self.exist?(path, base_dir: nil)
|
|
16
|
+
safe_path = sanitize(path, base_dir)
|
|
17
|
+
Dir.exist?(safe_path)
|
|
18
|
+
rescue SecurityError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Safely globs files
|
|
23
|
+
#
|
|
24
|
+
# @param pattern [String] The glob pattern
|
|
25
|
+
# @param base_dir [String] Optional base directory to restrict access to
|
|
26
|
+
# @return [Array<String>] Matching file paths
|
|
27
|
+
def self.glob(pattern, base_dir: nil)
|
|
28
|
+
# For glob, we need to be careful. Ideally we validate the base of the pattern.
|
|
29
|
+
# If pattern contains wildcards, we can't easily sanitize the whole string as a path.
|
|
30
|
+
# So we rely on the caller to have constructed the pattern safely (e.g. using safe_join).
|
|
31
|
+
# But we can still check if the resolved paths are safe if base_dir is provided.
|
|
32
|
+
|
|
33
|
+
Dir.glob(pattern).select do |path|
|
|
34
|
+
if base_dir
|
|
35
|
+
begin
|
|
36
|
+
PathSanitizer.sanitize_path(path, base_dir: base_dir)
|
|
37
|
+
true
|
|
38
|
+
rescue PathSanitizer::PathTraversalError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.sanitize(path, base_dir)
|
|
48
|
+
PathSanitizer.sanitize_path(path, base_dir: base_dir)
|
|
49
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
50
|
+
raise SecurityError, "Invalid directory path: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
private_class_method :sanitize
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_sanitizer"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
class SafeFile
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class NotFoundError < Error; end
|
|
9
|
+
class SecurityError < Error; end
|
|
10
|
+
|
|
11
|
+
# Safely reads a file from the filesystem
|
|
12
|
+
#
|
|
13
|
+
# @param path [String] The path to the file to read
|
|
14
|
+
# @param base_dir [String] Optional base directory to restrict access to
|
|
15
|
+
# @return [String] The file content
|
|
16
|
+
# @raise [NotFoundError] if the file does not exist or is not a file
|
|
17
|
+
# @raise [SecurityError] if the path is unsafe or outside base_dir
|
|
18
|
+
def self.read(path, base_dir: nil)
|
|
19
|
+
# 1. Sanitize and validate path
|
|
20
|
+
begin
|
|
21
|
+
safe_path = PathSanitizer.sanitize_path(path, base_dir: base_dir)
|
|
22
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
23
|
+
raise SecurityError, "Invalid file path: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 2. Check existence and type
|
|
27
|
+
unless File.exist?(safe_path)
|
|
28
|
+
raise NotFoundError, "File not found: #{path}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless File.file?(safe_path)
|
|
32
|
+
raise NotFoundError, "Path is not a regular file: #{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 3. Read content
|
|
36
|
+
File.read(safe_path)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/caruso/version.rb
CHANGED
data/lib/caruso.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "caruso/safe_file"
|
|
4
|
+
require_relative "caruso/safe_dir"
|
|
3
5
|
require_relative "caruso/version"
|
|
4
6
|
require_relative "caruso/config_manager"
|
|
5
7
|
require_relative "caruso/fetcher"
|
|
6
8
|
require_relative "caruso/adapter"
|
|
7
9
|
require_relative "caruso/marketplace_registry"
|
|
10
|
+
require_relative "caruso/remover"
|
|
8
11
|
|
|
9
12
|
require_relative "caruso/cli"
|
|
10
13
|
|