ruborg 0.2.0 โ†’ 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca72109e555872d29ba3b56a07b6ed10ea5b41fec351631232265c42216fd376
4
- data.tar.gz: 2049a2ba62a07171f2fc62237243043be6dacf83200f0ffa4f10b6a05285d5b1
3
+ metadata.gz: 0bb6c1c5f47b1fbb4538310a929914b0e744bcdb30d5e5635c4246df778e2113
4
+ data.tar.gz: bda5cf063fe587a047dd36fa9c5e948d8130e47501a53eabef9c34a3af7ae361
5
5
  SHA512:
6
- metadata.gz: ce9e39076fbb118801cf910ca241e930d2139e0f15c8adaf139a62e36bf8dad5a7495d81618db5f82d45d7c1e6c0784c143258afbc0c4ad524945d13b91ca345
7
- data.tar.gz: 94534dfd27da62827c5976bcbbe291d9c6906a7e3355905a970b6f9ea65bb3b7612ae50373445a249f2b57ad9283028ca06ab5db87d85f05e6ff76e3b6d34121
6
+ metadata.gz: 8828dacb76519557d51876327133491318bd199e589fb695d08a94af1a73a3c34772f022b6630e199a2c1ab9ab7d78dcce043a402bfa0f418ae51262aad80e8f
7
+ data.tar.gz: 99b7e79eb2e50240862f7ade79291d9a76469385f2b7e8949df8e6ac3251f9adcbe2f605e42c09364616c028f10575593c3e0a1df1d020bcf8fc7291a9cdb558
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2025-10-05
11
+
12
+ ### Added
13
+ - `borg_options` configuration for controlling Borg environment variables
14
+ - Repository path validation to prevent creation in system directories
15
+ - Backup path validation and normalization
16
+ - Archive name sanitization (alphanumeric, dash, underscore, dot only)
17
+
18
+ ### Changed
19
+ - Borg environment variables now configurable via `borg_options` (backward compatible)
20
+ - All backup paths are now normalized to absolute paths
21
+ - Custom archive names are automatically sanitized
22
+
23
+ ### Security
24
+ - Fixed command injection vulnerability in Passbolt CLI execution (now uses Open3.capture3)
25
+ - Added path traversal protection for extract operations
26
+ - Implemented symlink resolution and system path protection for --remove-source
27
+ - Changed to YAML.safe_load_file to prevent arbitrary code execution
28
+ - Added log path validation to prevent writing to system directories
29
+ - Added repository path validation (prevents /bin, /etc, /usr, etc.)
30
+ - Added backup path validation (rejects empty/nil paths)
31
+ - Added archive name sanitization (prevents injection attacks)
32
+ - Made Borg environment options configurable for enhanced security
33
+ - Added SECURITY.md with comprehensive security guidelines and best practices
34
+ - Enhanced test coverage for all security features
35
+
36
+ ## [0.3.0] - 2025-10-05
37
+
38
+ ### Added
39
+ - Auto-initialization feature: Set `auto_init: true` in config to automatically initialize repositories on first use
40
+ - Multi-repository configuration support with per-repository sources
41
+ - `--repository` / `-r` option to target specific repository in multi-repo configs
42
+ - `--all` option to backup all repositories at once
43
+ - Repository-specific Passbolt integration (overrides global settings)
44
+ - Per-source exclude patterns in multi-repo configs
45
+ - BackupConfig wrapper class for multi-repo compatibility
46
+ - Automatic format detection (single vs multi-repo)
47
+ - Support for multiple backup sources per repository
48
+ - Global settings with per-repository overrides
49
+ - `log_file` configuration option to set log path in config file
50
+ - Log file priority: CLI option > config file > default
51
+
52
+ ### Changed
53
+ - Config class now detects and handles both single-repo and multi-repo formats
54
+ - Backup command automatically routes to single or multi-repo implementation
55
+ - Archive naming includes repository name for multi-repo configs
56
+ - CLI now reads log_file from config if --log option not provided
57
+
10
58
  ## [0.2.0] - 2025-10-05
11
59
 
12
60
  ### Added
data/README.md CHANGED
@@ -15,7 +15,10 @@ A friendly Ruby frontend for [Borg Backup](https://www.borgbackup.org/). Ruborg
15
15
  - ๐Ÿ—‚๏ธ **Selective Restore** - Restore individual files or directories from archives
16
16
  - ๐Ÿงน **Auto-cleanup** - Optionally remove source files after successful backup
17
17
  - ๐Ÿ“Š **Logging** - Comprehensive logging with daily rotation
18
+ - ๐Ÿ—„๏ธ **Multi-Repository** - Manage multiple backup repositories with different sources
19
+ - ๐Ÿ”„ **Auto-initialization** - Automatically initialize repositories on first use
18
20
  - โœ… **Well-tested** - Comprehensive test suite with RSpec
21
+ - ๐Ÿ”’ **Security-focused** - Path validation, safe YAML loading, command injection protection
19
22
 
20
23
  ## Prerequisites
21
24
 
@@ -63,7 +66,9 @@ gem install ruborg
63
66
 
64
67
  ## Configuration
65
68
 
66
- Create a `ruborg.yml` configuration file:
69
+ Ruborg supports two configuration formats: **single repository** (legacy) and **multi-repository** (recommended for complex setups).
70
+
71
+ ### Single Repository Configuration
67
72
 
68
73
  ```yaml
69
74
  # Repository path
@@ -73,15 +78,11 @@ repository: /path/to/borg/repository
73
78
  backup_paths:
74
79
  - /home/user/documents
75
80
  - /home/user/projects
76
- - /etc
77
81
 
78
82
  # Exclude patterns
79
83
  exclude_patterns:
80
84
  - "*.tmp"
81
85
  - "*.log"
82
- - "*/.cache/*"
83
- - "*/node_modules/*"
84
- - "*/.git/*"
85
86
 
86
87
  # Compression algorithm (lz4, zstd, zlib, lzma, none)
87
88
  compression: lz4
@@ -92,9 +93,70 @@ encryption: repokey
92
93
  # Passbolt integration (optional)
93
94
  passbolt:
94
95
  resource_id: "your-passbolt-resource-uuid"
96
+
97
+ # Auto-initialize repository (optional, default: false)
98
+ auto_init: true
99
+
100
+ # Log file path (optional, default: ~/.ruborg/logs/ruborg.log)
101
+ log_file: /var/log/ruborg.log
102
+
103
+ # Borg environment options (optional)
104
+ borg_options:
105
+ allow_relocated_repo: true # Allow relocated repositories (default: true)
106
+ allow_unencrypted_repo: true # Allow unencrypted repositories (default: true)
107
+ ```
108
+
109
+ ### Multi-Repository Configuration
110
+
111
+ For managing multiple repositories with different sources:
112
+
113
+ ```yaml
114
+ # Global settings (applied to all repositories unless overridden)
115
+ compression: lz4
116
+ encryption: repokey
117
+ auto_init: true
118
+ passbolt:
119
+ resource_id: "global-passbolt-id"
120
+ borg_options:
121
+ allow_relocated_repo: false
122
+ allow_unencrypted_repo: false
123
+
124
+ # Multiple repositories
125
+ repositories:
126
+ - name: documents
127
+ path: /mnt/backup/documents
128
+ sources:
129
+ - name: home-docs
130
+ paths:
131
+ - /home/user/documents
132
+ exclude:
133
+ - "*.tmp"
134
+ - name: work-docs
135
+ paths:
136
+ - /home/user/work
137
+ exclude:
138
+ - "*.log"
139
+
140
+ - name: databases
141
+ path: /mnt/backup/databases
142
+ # Repository-specific passbolt (overrides global)
143
+ passbolt:
144
+ resource_id: "db-specific-passbolt-id"
145
+ sources:
146
+ - name: mysql
147
+ paths:
148
+ - /var/lib/mysql/dumps
149
+ - name: postgres
150
+ paths:
151
+ - /var/lib/postgresql/dumps
95
152
  ```
96
153
 
97
- See `ruborg.yml.example` for a complete configuration template.
154
+ **Multi-repo benefits:**
155
+ - Organize backups by type (documents, databases, media)
156
+ - Different encryption keys per repository
157
+ - Multiple sources per repository
158
+ - Per-source exclude patterns
159
+ - Repository-specific settings override global ones
98
160
 
99
161
  ## Usage
100
162
 
@@ -110,6 +172,7 @@ ruborg init /path/to/repository --passbolt-id "resource-uuid"
110
172
 
111
173
  ### Create a Backup
112
174
 
175
+ **Single repository:**
113
176
  ```bash
114
177
  # Using default configuration (ruborg.yml)
115
178
  ruborg backup
@@ -124,6 +187,18 @@ ruborg backup --name "my-backup-2025-10-04"
124
187
  ruborg backup --remove-source
125
188
  ```
126
189
 
190
+ **Multi-repository:**
191
+ ```bash
192
+ # Backup specific repository
193
+ ruborg backup --repository documents
194
+
195
+ # Backup all repositories
196
+ ruborg backup --all
197
+
198
+ # Backup specific repository with custom name
199
+ ruborg backup --repository databases --name "db-backup-2025-10-05"
200
+ ```
201
+
127
202
  ### List Archives
128
203
 
129
204
  ```bash
@@ -151,13 +226,23 @@ ruborg info
151
226
 
152
227
  ## Logging
153
228
 
154
- Ruborg automatically logs all operations to `~/.ruborg/logs/ruborg.log` with daily rotation. You can specify a custom log file:
229
+ Ruborg automatically logs all operations with daily rotation. Log file location priority:
230
+
231
+ 1. **CLI option** (highest priority): `--log /path/to/custom.log`
232
+ 2. **Config file**: `log_file: /path/to/log.log`
233
+ 3. **Default**: `~/.ruborg/logs/ruborg.log`
234
+
235
+ **Examples:**
155
236
 
156
237
  ```bash
157
- ruborg backup --log /path/to/custom.log
238
+ # Use CLI option (overrides config)
239
+ ruborg backup --log /var/log/ruborg.log
240
+
241
+ # Or set in config file
242
+ log_file: /var/log/ruborg.log
158
243
  ```
159
244
 
160
- Logs include:
245
+ **Logs include:**
161
246
  - Operation start/completion timestamps
162
247
  - Paths being backed up
163
248
  - Archive names created
@@ -191,20 +276,68 @@ passbolt:
191
276
 
192
277
  Ruborg will automatically retrieve the passphrase when performing backup operations.
193
278
 
279
+ ## Auto-initialization
280
+
281
+ Set `auto_init: true` in your configuration file to automatically initialize the repository on first use:
282
+
283
+ ```yaml
284
+ repository: /path/to/borg/repository
285
+ auto_init: true
286
+ passbolt:
287
+ resource_id: "your-passbolt-resource-uuid"
288
+ backup_paths:
289
+ - /path/to/backup
290
+ ```
291
+
292
+ When enabled, ruborg will automatically run `borg init` if the repository doesn't exist when you run `backup`, `list`, or `info` commands. The passphrase will be retrieved from Passbolt if configured.
293
+
294
+ ## Security Configuration
295
+
296
+ Ruborg provides configurable security options via `borg_options`:
297
+
298
+ ```yaml
299
+ borg_options:
300
+ # Control whether to allow access to relocated repositories
301
+ # Set to false in production for enhanced security
302
+ allow_relocated_repo: true # default: true
303
+
304
+ # Control whether to allow access to unencrypted repositories
305
+ # Set to false to enforce encryption
306
+ allow_unencrypted_repo: true # default: true
307
+ ```
308
+
309
+ **Security Features:**
310
+ - **Repository Path Validation**: Prevents creation in system directories (`/bin`, `/etc`, `/usr`, etc.)
311
+ - **Backup Path Validation**: Validates and normalizes all backup source paths
312
+ - **Archive Name Sanitization**: Automatically sanitizes custom archive names
313
+ - **Path Traversal Protection**: Prevents extraction to system directories
314
+ - **Symlink Protection**: Resolves and validates symlinks before deletion with `--remove-source`
315
+ - **Safe YAML Loading**: Uses `YAML.safe_load_file` to prevent code execution
316
+ - **Command Injection Protection**: Uses safe command execution methods
317
+ - **Log Path Validation**: Prevents writing logs to system directories
318
+
319
+ See [SECURITY.md](SECURITY.md) for detailed security information and best practices.
320
+
194
321
  ## Command Reference
195
322
 
196
323
  | Command | Description | Options |
197
324
  |---------|-------------|---------|
198
325
  | `init REPOSITORY` | Initialize a new Borg repository | `--passphrase`, `--passbolt-id`, `--log` |
199
- | `backup` | Create a backup using config file | `--config`, `--name`, `--remove-source`, `--log` |
200
- | `list` | List all archives in repository | `--config`, `--log` |
201
- | `restore ARCHIVE` | Restore files from archive | `--config`, `--destination`, `--path`, `--log` |
202
- | `info` | Show repository information | `--config`, `--log` |
326
+ | `backup` | Create a backup using config file | `--config`, `--name`, `--remove-source`, `--repository`, `--all`, `--log` |
327
+ | `list` | List all archives in repository | `--config`, `--repository`, `--log` |
328
+ | `restore ARCHIVE` | Restore files from archive | `--config`, `--destination`, `--path`, `--repository`, `--log` |
329
+ | `info` | Show repository information | `--config`, `--repository`, `--log` |
203
330
 
204
331
  ### Global Options
205
332
 
206
333
  - `--config`: Path to configuration file (default: `ruborg.yml`)
207
- - `--log`: Path to log file (default: `~/.ruborg/logs/ruborg.log`)
334
+ - `--log`: Path to log file (overrides config, default: `~/.ruborg/logs/ruborg.log`)
335
+ - `--repository` / `-r`: Repository name (required for multi-repo configs)
336
+
337
+ ### Multi-Repository Options
338
+
339
+ - `--all`: Backup all repositories (multi-repo config only)
340
+ - `--repository NAME`: Target specific repository by name
208
341
 
209
342
  ## Development
210
343
 
@@ -244,6 +377,7 @@ The test suite includes:
244
377
  - Passbolt integration (mocked)
245
378
  - CLI commands
246
379
  - Logging functionality
380
+ - Comprehensive security tests (path validation, sanitization, etc.)
247
381
 
248
382
  ## Contributing
249
383
 
data/SECURITY.md ADDED
@@ -0,0 +1,173 @@
1
+ # Security Policy
2
+
3
+ ## Security Features
4
+
5
+ Ruborg implements several security measures to protect your backup operations:
6
+
7
+ ### 1. Command Injection Prevention
8
+ - Uses `Open3.capture3` for Passbolt CLI execution (no shell interpolation)
9
+ - Array-based command construction for Borg commands
10
+ - No user input directly interpolated into shell commands
11
+
12
+ ### 2. Path Traversal Protection
13
+ - Validates all destination paths during restore operations
14
+ - Prevents extraction to system directories (`/`, `/etc`, `/bin`, `/usr`, etc.)
15
+ - Normalizes paths to prevent `../` traversal attacks
16
+
17
+ ### 3. Symlink Protection
18
+ - Resolves symlinks before file deletion with `--remove-source`
19
+ - Refuses to delete system directories even when targeted via symlinks
20
+ - Uses `FileUtils.rm_rf` with `secure: true` option
21
+
22
+ ### 4. Safe YAML Loading
23
+ - Uses `YAML.safe_load_file` to prevent arbitrary code execution
24
+ - Rejects YAML files containing Ruby objects or other dangerous constructs
25
+ - Only permits basic data types and Symbol class
26
+
27
+ ### 5. Log Path Validation
28
+ - Validates log file paths to prevent writing to system directories
29
+ - Automatically creates log directories with proper permissions
30
+ - Rejects paths in `/bin`, `/etc`, `/usr`, and other sensitive locations
31
+
32
+ ### 6. Passphrase Handling
33
+ - Passes passphrases via environment variables (never CLI arguments)
34
+ - Prevents passphrase leakage in process listings
35
+ - Uses Passbolt integration for secure password retrieval
36
+
37
+ ### 7. Repository Path Validation
38
+ - Validates repository paths to prevent creation in system directories
39
+ - Rejects empty or nil repository paths
40
+ - Prevents accidental repository creation in `/bin`, `/etc`, `/usr`, etc.
41
+
42
+ ### 8. Backup Path Validation
43
+ - Validates all backup source paths before creating archives
44
+ - Rejects empty, nil, or whitespace-only paths
45
+ - Normalizes relative paths to absolute paths for consistency
46
+
47
+ ### 9. Archive Name Sanitization
48
+ - Sanitizes user-provided archive names to prevent injection attacks
49
+ - Allows only alphanumeric characters, dashes, underscores, and dots
50
+ - Rejects archive names that would become empty after sanitization
51
+
52
+ ### 10. Configurable Borg Environment Options
53
+ - Allows control over relocated repository access via config
54
+ - Allows control over unencrypted repository access via config
55
+ - Defaults to safe settings while maintaining backward compatibility
56
+
57
+ ## Security Best Practices
58
+
59
+ ### When Using `--remove-source`
60
+ โš ๏ธ **Warning**: The `--remove-source` flag permanently deletes source files after backup.
61
+
62
+ **Recommendations:**
63
+ 1. **Test first** without `--remove-source` to verify backups work
64
+ 2. **Never use on symlinks** to critical system directories
65
+ 3. **Verify backups** before using this flag in production
66
+ 4. **Use absolute paths** in configuration to avoid ambiguity
67
+
68
+ ### Configuration File Security
69
+ - Store configuration files with restricted permissions: `chmod 600 ruborg.yml`
70
+ - Never commit Passbolt resource IDs to public repositories
71
+ - Use environment variables for sensitive paths when possible
72
+
73
+ ### Repository Security
74
+ - Use encrypted repositories (default: `encryption: repokey`)
75
+ - Store passphrases in Passbolt, not in config files
76
+ - Use different encryption keys for different repository types
77
+ - Regularly rotate Passbolt passphrases
78
+ - Avoid creating repositories in system directories
79
+ - Use absolute paths for repository locations
80
+
81
+ ### Multi-Repository Considerations
82
+ - Each repository can have its own Passbolt resource ID
83
+ - Validate all source paths before adding to configuration
84
+ - Review exclude patterns to ensure no sensitive files leak
85
+
86
+ ### Archive Naming
87
+ - Use default timestamp-based names when possible
88
+ - If providing custom names, use only alphanumeric characters, dashes, and underscores
89
+ - Avoid special characters or path separators in archive names
90
+
91
+ ### Borg Environment Options
92
+ - Consider disabling `allow_relocated_repo` for production environments
93
+ - Consider disabling `allow_unencrypted_repo` for sensitive data
94
+ - Configure in `ruborg.yml`:
95
+ ```yaml
96
+ borg_options:
97
+ allow_relocated_repo: false # Reject relocated repositories
98
+ allow_unencrypted_repo: false # Reject unencrypted repositories
99
+ ```
100
+
101
+ ## Reporting Security Issues
102
+
103
+ If you discover a security vulnerability, please:
104
+
105
+ 1. **Do NOT** open a public issue
106
+ 2. Email security concerns to: mpantel@aegean.gr
107
+ 3. Include:
108
+ - Description of the vulnerability
109
+ - Steps to reproduce
110
+ - Potential impact
111
+ - Suggested fix (if any)
112
+
113
+ We will respond within 48 hours and work with you to address the issue.
114
+
115
+ ## Security Audit History
116
+
117
+ - **v0.3.1** (2025-10-05): Comprehensive security hardening
118
+ - Fixed command injection in Passbolt CLI execution (uses Open3.capture3)
119
+ - Added path traversal protection for extract operations
120
+ - Implemented symlink protection for file deletion with --remove-source
121
+ - Switched to safe YAML loading (YAML.safe_load_file)
122
+ - Added log path validation to prevent writing to system directories
123
+ - Added repository path validation to prevent creation in system directories
124
+ - Added backup path validation and normalization
125
+ - Implemented archive name sanitization
126
+ - Made Borg environment variables configurable
127
+ - Enhanced test coverage for all security features
128
+ - Created comprehensive SECURITY.md documentation
129
+
130
+ - **v0.3.0** (2025-10-05): Multi-repository and auto-initialization features
131
+ - Added multi-repository configuration support
132
+ - Added auto-initialization feature
133
+ - Added configurable log file paths
134
+ - No security-specific changes in this version
135
+
136
+ ## Dependency Security
137
+
138
+ Ruborg relies on:
139
+ - **Borg Backup**: Industry-standard backup tool with strong encryption
140
+ - **Passbolt CLI**: Secure password management
141
+ - **Ruby stdlib**: No external gems for core functionality (only Thor for CLI)
142
+
143
+ Keep dependencies updated:
144
+ ```bash
145
+ # Update Borg
146
+ brew upgrade borgbackup # macOS
147
+ sudo apt update && sudo apt upgrade borgbackup # Ubuntu
148
+
149
+ # Update Passbolt CLI
150
+ # Follow https://github.com/passbolt/go-passbolt-cli
151
+
152
+ # Update Ruby gems
153
+ bundle update
154
+ ```
155
+
156
+ ## Security Checklist
157
+
158
+ Before deploying ruborg in production:
159
+
160
+ - [ ] Review all paths in configuration files
161
+ - [ ] Set proper file permissions on config (600) and logs (640)
162
+ - [ ] Use Passbolt for all passphrases (never hardcode)
163
+ - [ ] Test restore operations before relying on backups
164
+ - [ ] Never use `--remove-source` without thorough testing
165
+ - [ ] Keep Borg and Passbolt CLI up to date
166
+ - [ ] Review exclude patterns for sensitive data
167
+ - [ ] Use absolute paths in configuration
168
+ - [ ] Enable auto_init only for trusted repository locations
169
+ - [ ] Regularly audit backup logs for anomalies
170
+ - [ ] Validate repository paths are not in system directories
171
+ - [ ] Configure borg_options for your security requirements
172
+ - [ ] Use default archive names or sanitized custom names only
173
+ - [ ] Ensure backup paths don't contain empty or nil values
data/lib/ruborg/backup.rb CHANGED
@@ -28,8 +28,12 @@ module Ruborg
28
28
  # Change to destination directory if specified
29
29
  if destination != "."
30
30
  require "fileutils"
31
- FileUtils.mkdir_p(destination) unless File.directory?(destination)
32
- Dir.chdir(destination) do
31
+
32
+ # Validate and normalize destination path
33
+ validated_dest = validate_destination_path(destination)
34
+ FileUtils.mkdir_p(validated_dest) unless File.directory?(validated_dest)
35
+
36
+ Dir.chdir(validated_dest) do
33
37
  execute_borg_command(cmd)
34
38
  end
35
39
  else
@@ -57,7 +61,10 @@ module Ruborg
57
61
  end
58
62
 
59
63
  cmd << "#{@repository.path}::#{archive_name}"
60
- cmd += @config.backup_paths
64
+
65
+ # Validate and normalize backup paths
66
+ validated_paths = validate_backup_paths(@config.backup_paths)
67
+ cmd += validated_paths
61
68
 
62
69
  cmd
63
70
  end
@@ -79,11 +86,53 @@ module Ruborg
79
86
  require "fileutils"
80
87
 
81
88
  @config.backup_paths.each do |path|
82
- if File.directory?(path)
83
- FileUtils.rm_rf(path)
84
- elsif File.file?(path)
85
- FileUtils.rm(path)
89
+ # Resolve symlinks and validate path
90
+ begin
91
+ real_path = File.realpath(path)
92
+ rescue Errno::ENOENT
93
+ # Path doesn't exist, skip
94
+ next
95
+ end
96
+
97
+ # Security check: ensure path hasn't been tampered with
98
+ unless File.exist?(real_path)
99
+ next
86
100
  end
101
+
102
+ # Additional safety: don't delete root or system directories
103
+ if real_path == "/" || real_path.start_with?("/bin", "/sbin", "/usr", "/etc", "/sys", "/proc")
104
+ raise BorgError, "Refusing to delete system path: #{real_path}"
105
+ end
106
+
107
+ if File.directory?(real_path)
108
+ FileUtils.rm_rf(real_path, secure: true)
109
+ elsif File.file?(real_path)
110
+ FileUtils.rm(real_path)
111
+ end
112
+ end
113
+ end
114
+
115
+ def validate_destination_path(destination)
116
+ # Expand and normalize the path
117
+ normalized_path = File.expand_path(destination)
118
+
119
+ # Security check: prevent path traversal to sensitive directories
120
+ forbidden_paths = ["/", "/bin", "/sbin", "/usr", "/etc", "/sys", "/proc", "/boot"]
121
+ forbidden_paths.each do |forbidden|
122
+ if normalized_path == forbidden || normalized_path.start_with?("#{forbidden}/")
123
+ raise BorgError, "Invalid destination: refusing to extract to system directory #{normalized_path}"
124
+ end
125
+ end
126
+
127
+ normalized_path
128
+ end
129
+
130
+ def validate_backup_paths(paths)
131
+ raise BorgError, "No backup paths specified" if paths.nil? || paths.empty?
132
+
133
+ paths.map do |path|
134
+ raise BorgError, "Empty backup path specified" if path.nil? || path.to_s.strip.empty?
135
+ File.expand_path(path)
87
136
  end
88
137
  end
89
138
  end
data/lib/ruborg/cli.rb CHANGED
@@ -7,10 +7,25 @@ module Ruborg
7
7
  class CLI < Thor
8
8
  class_option :config, type: :string, default: "ruborg.yml", desc: "Path to configuration file"
9
9
  class_option :log, type: :string, desc: "Path to log file"
10
+ class_option :repository, type: :string, aliases: "-r", desc: "Repository name (for multi-repo configs)"
10
11
 
11
12
  def initialize(*args)
12
13
  super
13
- @logger = RuborgLogger.new(log_file: options[:log])
14
+ # Priority: CLI option > config file > default
15
+ log_path = options[:log]
16
+ unless log_path
17
+ # Try to load config to get log_file setting
18
+ config_path = options[:config] || "ruborg.yml"
19
+ if File.exist?(config_path)
20
+ config_data = YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true) rescue {}
21
+ log_path = config_data["log_file"]
22
+ end
23
+ end
24
+
25
+ # Validate log path if provided
26
+ log_path = validate_log_path(log_path) if log_path
27
+
28
+ @logger = RuborgLogger.new(log_file: log_path)
14
29
  end
15
30
 
16
31
  desc "init REPOSITORY", "Initialize a new Borg repository"
@@ -31,26 +46,16 @@ module Ruborg
31
46
  desc "backup", "Create a backup using configuration file"
32
47
  option :name, type: :string, desc: "Archive name"
33
48
  option :remove_source, type: :boolean, default: false, desc: "Remove source files after successful backup"
49
+ option :all, type: :boolean, default: false, desc: "Backup all repositories (multi-repo config only)"
34
50
  def backup
35
51
  @logger.info("Starting backup operation with config: #{options[:config]}")
36
52
  config = Config.new(options[:config])
37
- @logger.info("Backing up paths: #{config.backup_paths.join(', ')}")
38
- passphrase = fetch_passphrase_from_config(config)
39
53
 
40
- repo = Repository.new(config.repository, passphrase: passphrase)
41
- backup = Backup.new(repo, config: config)
42
-
43
- archive_name = options[:name] || Time.now.strftime("%Y-%m-%d_%H-%M-%S")
44
- @logger.info("Creating archive: #{archive_name}")
45
- backup.create(name: options[:name], remove_source: options[:remove_source])
46
- @logger.info("Backup created successfully: #{archive_name}")
47
-
48
- if options[:remove_source]
49
- @logger.info("Removed source files: #{config.backup_paths.join(', ')}")
54
+ if config.multi_repo?
55
+ backup_multi_repo(config)
56
+ else
57
+ backup_single_repo(config)
50
58
  end
51
-
52
- puts "Backup created successfully"
53
- puts "Source files removed" if options[:remove_source]
54
59
  rescue Error => e
55
60
  @logger.error("Backup failed: #{e.message}")
56
61
  error_exit(e)
@@ -62,7 +67,15 @@ module Ruborg
62
67
  config = Config.new(options[:config])
63
68
  passphrase = fetch_passphrase_from_config(config)
64
69
 
65
- repo = Repository.new(config.repository, passphrase: passphrase)
70
+ repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
71
+
72
+ # Auto-initialize repository if configured
73
+ if config.auto_init? && !repo.exists?
74
+ @logger.info("Auto-initializing repository at #{config.repository}")
75
+ repo.create
76
+ puts "Repository auto-initialized at #{config.repository}"
77
+ end
78
+
66
79
  repo.list
67
80
  @logger.info("Successfully listed archives")
68
81
  rescue Error => e
@@ -79,7 +92,7 @@ module Ruborg
79
92
  config = Config.new(options[:config])
80
93
  passphrase = fetch_passphrase_from_config(config)
81
94
 
82
- repo = Repository.new(config.repository, passphrase: passphrase)
95
+ repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
83
96
  backup = Backup.new(repo, config: config)
84
97
 
85
98
  backup.extract(archive_name, destination: options[:destination], path: options[:path])
@@ -101,7 +114,15 @@ module Ruborg
101
114
  config = Config.new(options[:config])
102
115
  passphrase = fetch_passphrase_from_config(config)
103
116
 
104
- repo = Repository.new(config.repository, passphrase: passphrase)
117
+ repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
118
+
119
+ # Auto-initialize repository if configured
120
+ if config.auto_init? && !repo.exists?
121
+ @logger.info("Auto-initializing repository at #{config.repository}")
122
+ repo.create
123
+ puts "Repository auto-initialized at #{config.repository}"
124
+ end
125
+
105
126
  repo.info
106
127
  @logger.info("Successfully retrieved repository information")
107
128
  rescue Error => e
@@ -129,5 +150,168 @@ module Ruborg
129
150
  puts "Error: #{error.message}"
130
151
  exit 1
131
152
  end
153
+
154
+ def validate_log_path(log_path)
155
+ # Expand to absolute path
156
+ normalized_path = File.expand_path(log_path)
157
+
158
+ # Prevent writing to sensitive system directories
159
+ forbidden_paths = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/etc", "/sys", "/proc", "/boot"]
160
+ forbidden_paths.each do |forbidden|
161
+ if normalized_path.start_with?("#{forbidden}/")
162
+ raise ConfigError, "Invalid log path: refusing to write to system directory #{normalized_path}"
163
+ end
164
+ end
165
+
166
+ # Ensure parent directory exists or can be created
167
+ log_dir = File.dirname(normalized_path)
168
+ unless File.directory?(log_dir)
169
+ begin
170
+ FileUtils.mkdir_p(log_dir)
171
+ rescue => e
172
+ raise ConfigError, "Cannot create log directory #{log_dir}: #{e.message}"
173
+ end
174
+ end
175
+
176
+ normalized_path
177
+ end
178
+
179
+ # Single repository backup (legacy)
180
+ def backup_single_repo(config)
181
+ @logger.info("Backing up paths: #{config.backup_paths.join(', ')}")
182
+ passphrase = fetch_passphrase_from_config(config)
183
+
184
+ repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
185
+
186
+ # Auto-initialize repository if configured
187
+ if config.auto_init? && !repo.exists?
188
+ @logger.info("Auto-initializing repository at #{config.repository}")
189
+ repo.create
190
+ puts "Repository auto-initialized at #{config.repository}"
191
+ end
192
+
193
+ backup = Backup.new(repo, config: config)
194
+
195
+ archive_name = options[:name] ? sanitize_archive_name(options[:name]) : Time.now.strftime("%Y-%m-%d_%H-%M-%S")
196
+ @logger.info("Creating archive: #{archive_name}")
197
+ backup.create(name: archive_name, remove_source: options[:remove_source])
198
+ @logger.info("Backup created successfully: #{archive_name}")
199
+
200
+ if options[:remove_source]
201
+ @logger.info("Removed source files: #{config.backup_paths.join(', ')}")
202
+ end
203
+
204
+ puts "Backup created successfully"
205
+ puts "Source files removed" if options[:remove_source]
206
+ end
207
+
208
+ # Multi-repository backup
209
+ def backup_multi_repo(config)
210
+ global_settings = config.global_settings
211
+ repos_to_backup = if options[:all]
212
+ config.repositories
213
+ elsif options[:repository]
214
+ repo_config = config.get_repository(options[:repository])
215
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
216
+ [repo_config]
217
+ else
218
+ raise ConfigError, "Please specify --repository or --all for multi-repo config"
219
+ end
220
+
221
+ repos_to_backup.each do |repo_config|
222
+ backup_repository(repo_config, global_settings)
223
+ end
224
+ end
225
+
226
+ def backup_repository(repo_config, global_settings)
227
+ repo_name = repo_config["name"]
228
+ puts "\n--- Backing up repository: #{repo_name} ---"
229
+ @logger.info("Backing up repository: #{repo_name}")
230
+
231
+ # Merge global settings with repo-specific settings (repo-specific takes precedence)
232
+ merged_config = global_settings.merge(repo_config)
233
+
234
+ passphrase = fetch_passphrase_for_repo(merged_config)
235
+ borg_opts = merged_config["borg_options"] || {}
236
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts)
237
+
238
+ # Auto-initialize if configured
239
+ auto_init = merged_config["auto_init"] || false
240
+ if auto_init && !repo.exists?
241
+ @logger.info("Auto-initializing repository at #{repo_config['path']}")
242
+ repo.create
243
+ puts "Repository auto-initialized at #{repo_config['path']}"
244
+ end
245
+
246
+ # Create backup config wrapper
247
+ backup_config = BackupConfig.new(repo_config, merged_config)
248
+ backup = Backup.new(repo, config: backup_config)
249
+
250
+ archive_name = options[:name] ? sanitize_archive_name(options[:name]) : "#{repo_name}-#{Time.now.strftime('%Y-%m-%d_%H-%M-%S')}"
251
+ @logger.info("Creating archive: #{archive_name}")
252
+
253
+ sources = repo_config["sources"] || []
254
+ @logger.info("Backing up #{sources.size} source(s)")
255
+
256
+ backup.create(name: archive_name, remove_source: options[:remove_source])
257
+ @logger.info("Backup created successfully: #{archive_name}")
258
+
259
+ puts "โœ“ Backup created: #{archive_name}"
260
+ puts " Sources removed" if options[:remove_source]
261
+ end
262
+
263
+ def fetch_passphrase_for_repo(repo_config)
264
+ passbolt_config = repo_config["passbolt"]
265
+ return nil if passbolt_config.nil? || passbolt_config.empty?
266
+
267
+ Passbolt.new(resource_id: passbolt_config["resource_id"]).get_password
268
+ end
269
+
270
+ def sanitize_archive_name(name)
271
+ raise ConfigError, "Archive name cannot be empty" if name.nil? || name.strip.empty?
272
+
273
+ # Check if name contains at least one valid character before sanitization
274
+ unless name =~ /[a-zA-Z0-9._-]/
275
+ raise ConfigError, "Invalid archive name: must contain at least one valid character (alphanumeric, dot, dash, or underscore)"
276
+ end
277
+
278
+ # Allow only alphanumeric, dash, underscore, and dot
279
+ sanitized = name.gsub(/[^a-zA-Z0-9._-]/, '_')
280
+
281
+ sanitized
282
+ end
283
+
284
+ # Wrapper class to adapt multi-repo config to existing Backup class
285
+ class BackupConfig
286
+ def initialize(repo_config, merged_settings)
287
+ @repo_config = repo_config
288
+ @merged_settings = merged_settings
289
+ end
290
+
291
+ def backup_paths
292
+ sources = @repo_config["sources"] || []
293
+ sources.flat_map do |source|
294
+ source["paths"] || []
295
+ end
296
+ end
297
+
298
+ def exclude_patterns
299
+ patterns = []
300
+ sources = @repo_config["sources"] || []
301
+ sources.each do |source|
302
+ patterns += (source["exclude"] || [])
303
+ end
304
+ patterns += (@merged_settings["exclude_patterns"] || [])
305
+ patterns.uniq
306
+ end
307
+
308
+ def compression
309
+ @merged_settings["compression"] || "lz4"
310
+ end
311
+
312
+ def encryption_mode
313
+ @merged_settings["encryption"] || "repokey"
314
+ end
315
+ end
132
316
  end
133
317
  end
data/lib/ruborg/config.rb CHANGED
@@ -11,16 +11,20 @@ module Ruborg
11
11
  def initialize(config_path)
12
12
  @config_path = config_path
13
13
  load_config
14
+ detect_format
14
15
  end
15
16
 
16
17
  def load_config
17
18
  raise ConfigError, "Configuration file not found: #{@config_path}" unless File.exist?(@config_path)
18
19
 
19
- @data = YAML.load_file(@config_path)
20
+ @data = YAML.safe_load_file(@config_path, permitted_classes: [Symbol], aliases: true)
20
21
  rescue Psych::SyntaxError => e
21
22
  raise ConfigError, "Invalid YAML syntax: #{e.message}"
23
+ rescue Psych::DisallowedClass => e
24
+ raise ConfigError, "Invalid YAML content: #{e.message}"
22
25
  end
23
26
 
27
+ # Legacy single-repo accessors (for backward compatibility)
24
28
  def repository
25
29
  @data["repository"]
26
30
  end
@@ -30,19 +34,98 @@ module Ruborg
30
34
  end
31
35
 
32
36
  def exclude_patterns
33
- @data["exclude_patterns"] || []
37
+ patterns = @data["exclude_patterns"] || []
38
+ validate_exclude_patterns(patterns)
34
39
  end
35
40
 
36
41
  def compression
37
- @data["compression"] || "lz4"
42
+ value = @data["compression"] || "lz4"
43
+ validate_compression(value)
38
44
  end
39
45
 
40
46
  def encryption_mode
41
- @data["encryption"] || "repokey"
47
+ value = @data["encryption"] || "repokey"
48
+ validate_encryption(value)
42
49
  end
43
50
 
44
51
  def passbolt_integration
45
52
  @data["passbolt"] || {}
46
53
  end
54
+
55
+ def auto_init?
56
+ @data["auto_init"] || false
57
+ end
58
+
59
+ def log_file
60
+ @data["log_file"]
61
+ end
62
+
63
+ def borg_options
64
+ @data["borg_options"] || {}
65
+ end
66
+
67
+ # New multi-repo support
68
+ def multi_repo?
69
+ @multi_repo
70
+ end
71
+
72
+ def repositories
73
+ return [] unless multi_repo?
74
+ @data["repositories"] || []
75
+ end
76
+
77
+ def get_repository(name)
78
+ return nil unless multi_repo?
79
+ repositories.find { |r| r["name"] == name }
80
+ end
81
+
82
+ def repository_names
83
+ return [] unless multi_repo?
84
+ repositories.map { |r| r["name"] }
85
+ end
86
+
87
+ def global_settings
88
+ @data.slice("passbolt", "compression", "encryption", "auto_init")
89
+ end
90
+
91
+ private
92
+
93
+ VALID_COMPRESSION = ["lz4", "zstd", "zlib", "lzma", "none"].freeze
94
+ VALID_ENCRYPTION = ["repokey", "keyfile", "none", "authenticated", "repokey-blake2",
95
+ "keyfile-blake2", "authenticated-blake2"].freeze
96
+
97
+ def detect_format
98
+ @multi_repo = @data.key?("repositories")
99
+ end
100
+
101
+ def validate_compression(compression)
102
+ unless VALID_COMPRESSION.include?(compression)
103
+ raise ConfigError, "Invalid compression '#{compression}'. Must be one of: #{VALID_COMPRESSION.join(', ')}"
104
+ end
105
+ compression
106
+ end
107
+
108
+ def validate_encryption(encryption)
109
+ unless VALID_ENCRYPTION.include?(encryption)
110
+ raise ConfigError, "Invalid encryption mode '#{encryption}'. Must be one of: #{VALID_ENCRYPTION.join(', ')}"
111
+ end
112
+ encryption
113
+ end
114
+
115
+ def validate_exclude_patterns(patterns)
116
+ return patterns if patterns.empty?
117
+
118
+ patterns.each do |pattern|
119
+ if pattern.nil? || pattern.to_s.strip.empty?
120
+ raise ConfigError, "Exclude pattern cannot be empty or nil"
121
+ end
122
+
123
+ if pattern.length > 1000
124
+ raise ConfigError, "Exclude pattern too long (max 1000 characters): #{pattern[0..50]}..."
125
+ end
126
+ end
127
+
128
+ patterns
129
+ end
47
130
  end
48
131
  end
data/lib/ruborg/logger.rb CHANGED
@@ -10,7 +10,7 @@ module Ruborg
10
10
 
11
11
  def initialize(log_file: nil)
12
12
  @log_file = log_file || default_log_file
13
- ensure_log_directory
13
+ validate_and_ensure_log_directory
14
14
  @logger = Logger.new(@log_file, "daily")
15
15
  @logger.level = Logger::INFO
16
16
  @logger.formatter = proc do |severity, datetime, progname, msg|
@@ -45,8 +45,21 @@ module Ruborg
45
45
  dir
46
46
  end
47
47
 
48
- def ensure_log_directory
49
- FileUtils.mkdir_p(File.dirname(@log_file)) unless File.directory?(File.dirname(@log_file))
48
+ def validate_and_ensure_log_directory
49
+ # Validate log file path for security
50
+ normalized_path = File.expand_path(@log_file)
51
+
52
+ # Prevent writing to sensitive system directories
53
+ forbidden_paths = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/etc", "/sys", "/proc", "/boot"]
54
+ forbidden_paths.each do |forbidden|
55
+ if normalized_path.start_with?("#{forbidden}/")
56
+ raise ConfigError, "Invalid log path: refusing to write to system directory #{normalized_path}"
57
+ end
58
+ end
59
+
60
+ # Ensure log directory exists
61
+ log_dir = File.dirname(normalized_path)
62
+ FileUtils.mkdir_p(log_dir) unless File.directory?(log_dir)
50
63
  end
51
64
  end
52
65
  end
@@ -30,8 +30,9 @@ module Ruborg
30
30
  end
31
31
 
32
32
  def execute_command(cmd)
33
- output = `#{cmd.join(' ')}`
34
- [output, $?.success?]
33
+ require "open3"
34
+ stdout, stderr, status = Open3.capture3(*cmd)
35
+ [stdout, status.success?]
35
36
  end
36
37
 
37
38
  def parse_password(json_output)
@@ -5,9 +5,10 @@ module Ruborg
5
5
  class Repository
6
6
  attr_reader :path
7
7
 
8
- def initialize(path, passphrase: nil)
9
- @path = path
8
+ def initialize(path, passphrase: nil, borg_options: {})
9
+ @path = validate_repo_path(path)
10
10
  @passphrase = passphrase
11
+ @borg_options = borg_options
11
12
  end
12
13
 
13
14
  def exists?
@@ -37,11 +38,32 @@ module Ruborg
37
38
 
38
39
  private
39
40
 
41
+ def validate_repo_path(path)
42
+ raise BorgError, "Repository path cannot be empty" if path.nil? || path.empty?
43
+
44
+ normalized = File.expand_path(path)
45
+
46
+ # Prevent repository creation in critical system directories
47
+ forbidden = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/etc", "/sys", "/proc", "/boot", "/dev"]
48
+ forbidden.each do |forbidden_path|
49
+ if normalized == forbidden_path || normalized.start_with?("#{forbidden_path}/")
50
+ raise BorgError, "Invalid repository path: refusing to use system directory #{normalized}"
51
+ end
52
+ end
53
+
54
+ normalized
55
+ end
56
+
40
57
  def execute_borg_command(cmd)
41
58
  env = {}
42
59
  env["BORG_PASSPHRASE"] = @passphrase if @passphrase
43
- env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = "yes"
44
- env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = "yes"
60
+
61
+ # Apply Borg environment options from config (defaults to yes for backward compatibility)
62
+ allow_relocated = @borg_options.fetch("allow_relocated_repo", true)
63
+ allow_unencrypted = @borg_options.fetch("allow_unencrypted_repo", true)
64
+
65
+ env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = allow_relocated ? "yes" : "no"
66
+ env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = allow_unencrypted ? "yes" : "no"
45
67
 
46
68
  # Redirect stdin from /dev/null to prevent interactive prompts
47
69
  result = system(env, *cmd, in: "/dev/null")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruborg
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruborg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis
@@ -110,6 +110,7 @@ files:
110
110
  - LICENSE
111
111
  - README.md
112
112
  - Rakefile
113
+ - SECURITY.md
113
114
  - exe/ruborg
114
115
  - lib/ruborg.rb
115
116
  - lib/ruborg/backup.rb