• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CyberDuck79 / duckfile / 18097442731

29 Sep 2025 12:46PM UTC coverage: 79.573% (+0.04%) from 79.529%
18097442731

Pull #70

github

CyberDuck79
refactor: extract writeMetadataFile helper to reduce metadata writing duplication

- Add writeMetadataFile() helper function for consistent JSON metadata writing
- Replace duplicate json.Marshal + os.WriteFile patterns in remote.go
- Reduces code duplication in template and remote metadata handling
Pull Request #70: Phase 2: Implement separated targets and remotes configurations

592 of 686 new or added lines in 9 files covered. (86.3%)

24 existing lines in 5 files now uncovered.

2758 of 3466 relevant lines covered (79.57%)

11.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.86
/internal/config/security.go
1
package config
2

3
import (
4
        "fmt"
5
        "net/url"
6
        "os"
7
        "path/filepath"
8
        "strings"
9
        "time"
10

11
        "github.com/CyberDuck79/duckfile/internal/log"
12
        "gopkg.in/yaml.v3"
13
)
14

15
// SecurityConfig holds comprehensive security configuration loaded from
16
// environment variables, CLI flags, or configuration files to prevent supply-chain attacks.
17
// BuildSecurityConfigWithPrecedence builds a security configuration using enhanced precedence system.
18
// Precedence order (highest to lowest):
19
// 1. 🔒 Signed Security Config Files (tamper-proof, highest)
20
// 2. ⚡ CLI flags (high precedence when no signed config)
21
// 3. 🌍 Environment variables (system-level control)
22
// 4. 📄 Unsigned Security Config Files (lower to prevent bypass)
23
// 5. 🔓 No restrictions (backward compatibility)rity settings are kept separate from duck.yaml to prevent attackers
24
// from modifying both targets and security policies in the same commit.
25
type SecurityConfig struct {
26
        // Existing host restriction fields
27
        AllowedHosts []string `yaml:"allowedHosts,omitempty"`
28
        DeniedHosts  []string `yaml:"deniedHosts,omitempty"`
29
        StrictMode   bool     `yaml:"strictMode,omitempty"` // Fail if no restrictions are configured
30
        Source       string   `yaml:"source,omitempty"`     // "signed", "env", "cli", "unsigned", "none" for audit trail
31

32
        // NEW: Digital signature fields
33
        Signature *DigitalSignature `yaml:"signature,omitempty"`
34

35
        // NEW: Policy enforcement
36
        Enforcement *PolicyEnforcement `yaml:"enforcement,omitempty"`
37

38
        // NEW: File permission validation
39
        FilePermissions *FilePermissionPolicy `yaml:"filePermissions,omitempty"`
40

41
        // NEW: Metadata for audit trails
42
        Metadata *SecurityMetadata `yaml:"metadata,omitempty"`
43

44
        // NEW: Source tracking for precedence
45
        SourceFile string `yaml:"sourceFile,omitempty"` // Path to config file if loaded from file
46
        IsSigned   bool   `yaml:"isSigned,omitempty"`   // Whether this config was verified with a valid signature
47
        Version    int    `yaml:"version,omitempty"`    // Configuration version
48
}
49

50
// DigitalSignature holds signature verification information
51
type DigitalSignature struct {
52
        Algorithm string `yaml:"algorithm"` // "ed25519"
53
        KeyID     string `yaml:"keyId"`     // Key identifier for lookup
54
        Signature string `yaml:"signature"` // base64-encoded signature
55
}
56

57
// PolicyEnforcement defines security policy enforcement rules
58
type PolicyEnforcement struct {
59
        ForceChecksumValidation bool `yaml:"forceChecksumValidation"` // Fail if template has no checksum
60
        ForceCommitTracking     bool `yaml:"forceCommitTracking"`     // Fail if trackCommitHash=false
61
        DisableAutoUpdate       bool `yaml:"disableAutoUpdate"`       // Override autoUpdateOnChange settings
62
        StrictPolicyMode        bool `yaml:"strictPolicyMode"`        // Require security config to exist
63
        EnforceFilePermissions  bool `yaml:"enforceFilePermissions"`  // Validate file permissions according to policy
64
}
65

66
// FilePermissionPolicy defines file permission validation rules
67
type FilePermissionPolicy struct {
68
        EnforceOwnership         bool `yaml:"enforceOwnership"`         // Require proper ownership (root for system files)
69
        EnforceReadOnly          bool `yaml:"enforceReadOnly"`          // Require read-only permissions
70
        AllowGroupWrite          bool `yaml:"allowGroupWrite"`          // Allow group write permissions
71
        RequireSecureDirectories bool `yaml:"requireSecureDirectories"` // Parent dirs must be secure
72
}
73

74
// SecurityMetadata holds audit trail information
75
type SecurityMetadata struct {
76
        CreatedBy string    `yaml:"createdBy"`
77
        CreatedAt time.Time `yaml:"createdAt"`
78
        Purpose   string    `yaml:"purpose"`
79
        Version   int       `yaml:"version,omitempty"`
80
}
81

82
// SecurityConfigFile represents a discovered security configuration file
83
type SecurityConfigFile struct {
84
        Path       string
85
        Type       SecurityFileType
86
        Exists     bool
87
        Readable   bool
88
        HasSigFile bool // Whether a corresponding .sig file exists
89
}
90

91
// SecurityFileType indicates the type and precedence of a security config file
92
type SecurityFileType int
93

94
const (
95
        SecurityFileTypeSystem  SecurityFileType = iota // /etc/duckfile/security.yaml
96
        SecurityFileTypeUser                            // ~/.duckfile/security.yaml, ~/.config/duckfile/security.yaml
97
        SecurityFileTypeProject                         // ./.duckfile/security.yaml
98
)
99

100
// DiscoverSecurityConfigs discovers security configuration files in the standard hierarchy.
101
// Returns files in precedence order (highest to lowest):
102
// 1. System-wide: /etc/duckfile/security.{yaml,yml}
103
// 2. User-specific: ~/.duckfile/security.{yaml,yml}, ~/.config/duckfile/security.{yaml,yml}
104
// 3. Project-specific: ./.duckfile/security.yaml
105
func DiscoverSecurityConfigs() ([]*SecurityConfigFile, error) {
14✔
106
        var configs []*SecurityConfigFile
14✔
107

14✔
108
        // 1. System-wide configurations (highest precedence for signed configs)
14✔
109
        systemPaths := []string{
14✔
110
                "/etc/duckfile/security.yaml",
14✔
111
                "/etc/duckfile/security.yml",
14✔
112
        }
14✔
113

14✔
114
        for _, path := range systemPaths {
42✔
115
                if config := checkSecurityConfigFile(path, SecurityFileTypeSystem); config != nil {
28✔
116
                        configs = append(configs, config)
×
117
                }
×
118
        }
119

120
        // 2. User-specific configurations
121
        homeDir, err := os.UserHomeDir()
14✔
122
        if err == nil {
28✔
123
                userPaths := []string{
14✔
124
                        homeDir + "/.duckfile/security.yaml",
14✔
125
                        homeDir + "/.duckfile/security.yml",
14✔
126
                        homeDir + "/.config/duckfile/security.yaml",
14✔
127
                        homeDir + "/.config/duckfile/security.yml",
14✔
128
                }
14✔
129

14✔
130
                for _, path := range userPaths {
70✔
131
                        if config := checkSecurityConfigFile(path, SecurityFileTypeUser); config != nil {
57✔
132
                                configs = append(configs, config)
1✔
133
                        }
1✔
134
                }
135
        }
136

137
        // 3. Project-specific configurations (lowest precedence)
138
        projectPaths := []string{
14✔
139
                "./.duckfile/security.yaml",
14✔
140
                "./.duckfile/security.yml",
14✔
141
        }
14✔
142

14✔
143
        for _, path := range projectPaths {
42✔
144
                if config := checkSecurityConfigFile(path, SecurityFileTypeProject); config != nil {
37✔
145
                        configs = append(configs, config)
9✔
146
                }
9✔
147
        }
148

149
        return configs, nil
14✔
150
}
151

152
// DiscoverSecurityConfigsInDir discovers security configuration files relative to a specific directory.
153
// This is primarily for testing purposes.
154
func DiscoverSecurityConfigsInDir(baseDir string) ([]*SecurityConfigFile, error) {
1✔
155
        var configs []*SecurityConfigFile
1✔
156

1✔
157
        // 1. System-wide configurations (highest precedence for signed configs)
1✔
158
        systemPaths := []string{
1✔
159
                "/etc/duckfile/security.yaml",
1✔
160
                "/etc/duckfile/security.yml",
1✔
161
        }
1✔
162

1✔
163
        for _, path := range systemPaths {
3✔
164
                if config := checkSecurityConfigFile(path, SecurityFileTypeSystem); config != nil {
2✔
165
                        configs = append(configs, config)
×
166
                }
×
167
        }
168

169
        // 2. User-specific configurations
170
        homeDir, err := os.UserHomeDir()
1✔
171
        if err == nil {
2✔
172
                userPaths := []string{
1✔
173
                        homeDir + "/.duckfile/security.yaml",
1✔
174
                        homeDir + "/.duckfile/security.yml",
1✔
175
                        homeDir + "/.config/duckfile/security.yaml",
1✔
176
                        homeDir + "/.config/duckfile/security.yml",
1✔
177
                }
1✔
178

1✔
179
                for _, path := range userPaths {
5✔
180
                        if config := checkSecurityConfigFile(path, SecurityFileTypeUser); config != nil {
4✔
181
                                configs = append(configs, config)
×
182
                        }
×
183
                }
184
        }
185

186
        // 3. Project-specific configurations (lowest precedence) - relative to baseDir
187
        projectPaths := []string{
1✔
188
                filepath.Join(baseDir, ".duckfile", "security.yaml"),
1✔
189
                filepath.Join(baseDir, ".duckfile", "security.yml"),
1✔
190
        }
1✔
191

1✔
192
        for _, path := range projectPaths {
3✔
193
                if config := checkSecurityConfigFile(path, SecurityFileTypeProject); config != nil {
4✔
194
                        configs = append(configs, config)
2✔
195
                }
2✔
196
        }
197

198
        return configs, nil
1✔
199
}
200

201
// checkSecurityConfigFile checks if a security config file exists and is readable
202
func checkSecurityConfigFile(path string, fileType SecurityFileType) *SecurityConfigFile {
124✔
203
        config := &SecurityConfigFile{
124✔
204
                Path: path,
124✔
205
                Type: fileType,
124✔
206
        }
124✔
207

124✔
208
        // Check if main config file exists and is readable
124✔
209
        if info, err := os.Stat(path); err == nil && !info.IsDir() {
138✔
210
                config.Exists = true
14✔
211

14✔
212
                // Test readability
14✔
213
                if file, err := os.Open(path); err == nil {
28✔
214
                        config.Readable = true
14✔
215
                        file.Close() //nolint:errcheck
14✔
216
                }
14✔
217
        }
218

219
        // Check for corresponding signature file
220
        sigPath := path + ".sig"
124✔
221
        if _, err := os.Stat(sigPath); err == nil {
127✔
222
                config.HasSigFile = true
3✔
223
        }
3✔
224

225
        // Only return config if the main file exists
226
        if config.Exists {
138✔
227
                return config
14✔
228
        }
14✔
229
        return nil
110✔
230
}
231

232
// LoadSecurityConfigFromFile loads and parses a security configuration from a YAML file
233
// with optional signature verification
234
// loadSignatureData attempts to load signature data for a config file
235
func loadSignatureData(configPath string) ([]byte, bool, error) {
19✔
236
        sigPath := configPath + ".sig"
19✔
237
        if _, err := os.Stat(sigPath); err != nil {
34✔
238
                return nil, false, nil // No signature file
15✔
239
        }
15✔
240

241
        signature, err := LoadSignatureFromFile(sigPath)
4✔
242
        if err != nil {
4✔
NEW
243
                return nil, false, fmt.Errorf("failed to load signature for %s: %w", configPath, err)
×
NEW
244
        }
×
245

246
        return signature, true, nil
4✔
247
}
248

249
// verifyConfigSignature verifies the signature of a security config
250
func verifyConfigSignature(configData []byte, signature []byte, config *SecurityConfig, path string) error {
4✔
251
        if config.Signature == nil {
5✔
252
                return fmt.Errorf("signature file exists but config has no signature metadata in %s", path)
1✔
253
        }
1✔
254

255
        // Load the public key for verification
256
        publicKey, err := LoadPublicKey(config.Signature.KeyID)
3✔
257
        if err != nil {
3✔
NEW
258
                return fmt.Errorf("failed to load public key for verification of %s: %w", path, err)
×
NEW
259
        }
×
260

261
        // Verify the signature
262
        if err := VerifySignature(configData, signature, publicKey); err != nil {
4✔
263
                return fmt.Errorf("signature verification failed for %s: %w", path, err)
1✔
264
        }
1✔
265

266
        return nil
2✔
267
}
268

269
// validateConfigFilePermissions validates file permissions if enforcement is enabled
270
func validateConfigFilePermissions(config *SecurityConfig, path string) error {
17✔
271
        if config.Enforcement == nil || !config.Enforcement.EnforceFilePermissions || config.FilePermissions == nil {
29✔
272
                return nil // No validation needed
12✔
273
        }
12✔
274

275
        configFile := &SecurityConfigFile{
5✔
276
                Path:   path,
5✔
277
                Type:   DetermineSecurityFileType(path),
5✔
278
                Exists: true,
5✔
279
        }
5✔
280

5✔
281
        permResult, err := ValidateFilePermissions(configFile, config.FilePermissions)
5✔
282
        if err != nil {
5✔
NEW
283
                return fmt.Errorf("failed to validate file permissions for %s: %w", path, err)
×
NEW
284
        }
×
285

286
        if !permResult.Valid {
8✔
287
                issues := append(permResult.Issues, permResult.ParentDirIssues...)
3✔
288
                return fmt.Errorf("file permission validation failed for %s: %v", path, issues)
3✔
289
        }
3✔
290

291
        return nil
2✔
292
}
293

294
func LoadSecurityConfigFromFile(path string) (*SecurityConfig, error) {
19✔
295
        // Read the configuration file
19✔
296
        configData, err := os.ReadFile(path)
19✔
297
        if err != nil {
19✔
298
                return nil, fmt.Errorf("failed to read security config file %s: %w", path, err)
×
299
        }
×
300

301
        // Load signature data if available
302
        signature, isSigned, err := loadSignatureData(path)
19✔
303
        if err != nil {
19✔
NEW
304
                return nil, err
×
UNCOV
305
        }
×
306

307
        // Parse the YAML configuration
308
        var config SecurityConfig
19✔
309
        if err := yaml.Unmarshal(configData, &config); err != nil {
19✔
310
                return nil, fmt.Errorf("failed to parse security config YAML from %s: %w", path, err)
×
311
        }
×
312

313
        // Set basic metadata
314
        config.SourceFile = path
19✔
315
        config.IsSigned = isSigned
19✔
316
        if config.Version == 0 {
20✔
317
                config.Version = 1 // Default to version 1 if not specified
1✔
318
        }
1✔
319

320
        // Handle signature verification
321
        if isSigned {
23✔
322
                if err := verifyConfigSignature(configData, signature, &config, path); err != nil {
6✔
323
                        return nil, err
2✔
324
                }
2✔
325
                config.Source = "signed"
2✔
326
        } else {
15✔
327
                config.Source = "unsigned"
15✔
328
        }
15✔
329

330
        // Validate file permissions if required
331
        if err := validateConfigFilePermissions(&config, path); err != nil {
20✔
332
                return nil, err
3✔
333
        }
3✔
334

335
        return &config, nil
14✔
336
}
337

338
// BuildSecurityConfigWithPrecedence implements the enhanced precedence system
339
// Precedence order (highest to lowest):
340
// 1. 🔒 Signed Security Config Files (tamper-proof, highest)
341
// 2. ⚡ CLI flags (high precedence when no signed config)
342
// 3. 🌍 Environment variables (system-level control)
343
// 4. 📄 Unsigned Security Config Files (lower to prevent bypass)
344
// 5. 🔓 No restrictions (backward compatibility)
345
func BuildSecurityConfigWithPrecedence(cliAllowed []string, cliDenied []string, cliStrict bool) (*SecurityConfig, error) {
9✔
346
        // Discover available security config files
9✔
347
        configFiles, err := DiscoverSecurityConfigs()
9✔
348
        if err != nil {
9✔
349
                return nil, fmt.Errorf("failed to discover security config files: %w", err)
×
350
        }
×
351

352
        // Separate signed and unsigned files
353
        var signedConfigs []*SecurityConfig
9✔
354
        var unsignedConfigs []*SecurityConfig
9✔
355

9✔
356
        for _, configFile := range configFiles {
13✔
357
                if !configFile.Exists || !configFile.Readable {
4✔
358
                        continue
×
359
                }
360

361
                config, err := LoadSecurityConfigFromFile(configFile.Path)
4✔
362
                if err != nil {
5✔
363
                        // Log error but continue with other configs for resilience
1✔
364
                        log.Debugf("Failed to load security config from %s: %v", configFile.Path, err)
1✔
365
                        continue
1✔
366
                }
367

368
                if config.IsSigned {
3✔
369
                        signedConfigs = append(signedConfigs, config)
×
370
                } else {
3✔
371
                        unsignedConfigs = append(unsignedConfigs, config)
3✔
372
                }
3✔
373
        }
374

375
        // 1. Check for signed configurations first (highest precedence)
376
        if len(signedConfigs) > 0 {
9✔
377
                // Use the first signed config (they were discovered in precedence order)
×
378
                // Signed configs cannot be overridden - they represent the highest authority
×
379
                return signedConfigs[0], nil
×
380
        }
×
381

382
        // 2. CLI flags (high precedence when no signed config exists)
383
        if len(cliAllowed) > 0 || len(cliDenied) > 0 || cliStrict {
13✔
384
                cliConfig := &SecurityConfig{
4✔
385
                        AllowedHosts: cliAllowed,
4✔
386
                        DeniedHosts:  cliDenied,
4✔
387
                        StrictMode:   cliStrict,
4✔
388
                        Source:       "cli",
4✔
389
                        Version:      1,
4✔
390
                }
4✔
391

4✔
392
                return cliConfig, nil
4✔
393
        }
4✔
394

395
        // 3. Environment variables (system-level control)
396
        envConfig := LoadSecurityConfigFromEnv()
5✔
397
        if envConfig.Source != "none" {
6✔
398
                return envConfig, nil
1✔
399
        }
1✔
400

401
        // 4. Unsigned config files (lower precedence to prevent bypass)
402
        if len(unsignedConfigs) > 0 {
6✔
403
                return unsignedConfigs[0], nil
2✔
404
        }
2✔
405

406
        // 5. No restrictions (backward compatibility)
407
        return &SecurityConfig{
2✔
408
                Source:  "none",
2✔
409
                Version: 1,
2✔
410
        }, nil
2✔
411
}
412

413
// BuildSecurityConfigWithFiles implements the original precedence system for backward compatibility
414
// Precedence order (highest to lowest):
415
// 1. Signed Security Config Files (tamper-proof, highest)
416
// 2. CLI flags (high precedence when no signed config)
417
// 3. Environment variables (system-level control)
418
// 4. Unsigned Security Config Files (lower to prevent bypass)
419
// 5. No restrictions (backward compatibility)
420
func BuildSecurityConfigWithFiles(cliAllowed []string, cliDenied []string, cliStrict bool) (*SecurityConfig, error) {
1✔
421
        return BuildSecurityConfigWithPrecedence(cliAllowed, cliDenied, cliStrict)
1✔
422
}
1✔
423

424
// MergeSecurityConfigs merges multiple security configurations with precedence rules
425
// Higher precedence configs override lower precedence ones for specific fields
426
// copyStringSlice creates a copy of a string slice
427
func copyStringSlice(src []string) []string {
3✔
428
        if len(src) == 0 {
3✔
NEW
429
                return nil
×
NEW
430
        }
×
431
        dst := make([]string, len(src))
3✔
432
        copy(dst, src)
3✔
433
        return dst
3✔
434
}
435

436
// createBaseConfig creates the initial merged config from the first configuration
437
func createBaseConfig(baseConfig *SecurityConfig) *SecurityConfig {
1✔
438
        return &SecurityConfig{
1✔
439
                AllowedHosts:    copyStringSlice(baseConfig.AllowedHosts),
1✔
440
                DeniedHosts:     copyStringSlice(baseConfig.DeniedHosts),
1✔
441
                StrictMode:      baseConfig.StrictMode,
1✔
442
                Source:          baseConfig.Source,
1✔
443
                SourceFile:      baseConfig.SourceFile,
1✔
444
                IsSigned:        baseConfig.IsSigned,
1✔
445
                Signature:       baseConfig.Signature,
1✔
446
                Enforcement:     baseConfig.Enforcement,
1✔
447
                FilePermissions: baseConfig.FilePermissions,
1✔
448
                Metadata:        baseConfig.Metadata,
1✔
449
                Version:         baseConfig.Version,
1✔
450
        }
1✔
451
}
1✔
452

453
// mergeConfigOverrides applies a config's overrides to the merged result
454
func mergeConfigOverrides(merged *SecurityConfig, override *SecurityConfig) {
1✔
455
        // Override host lists if provided
1✔
456
        if len(override.AllowedHosts) > 0 {
2✔
457
                merged.AllowedHosts = copyStringSlice(override.AllowedHosts)
1✔
458
        }
1✔
459
        if len(override.DeniedHosts) > 0 {
1✔
NEW
460
                merged.DeniedHosts = copyStringSlice(override.DeniedHosts)
×
NEW
461
        }
×
462

463
        // Always override boolean and core fields
464
        merged.StrictMode = override.StrictMode
1✔
465
        merged.Source = override.Source
1✔
466

1✔
467
        // Override optional fields if present
1✔
468
        if override.SourceFile != "" {
2✔
469
                merged.SourceFile = override.SourceFile
1✔
470
        }
1✔
471
        if override.IsSigned {
2✔
472
                merged.IsSigned = override.IsSigned
1✔
473
        }
1✔
474
        if override.Signature != nil {
1✔
NEW
475
                merged.Signature = override.Signature
×
NEW
476
        }
×
477
        if override.Enforcement != nil {
1✔
NEW
478
                merged.Enforcement = override.Enforcement
×
NEW
479
        }
×
480
        if override.FilePermissions != nil {
1✔
NEW
481
                merged.FilePermissions = override.FilePermissions
×
NEW
482
        }
×
483
        if override.Metadata != nil {
1✔
NEW
484
                merged.Metadata = override.Metadata
×
NEW
485
        }
×
486
        if override.Version > 0 {
2✔
487
                merged.Version = override.Version
1✔
488
        }
1✔
489
}
490

491
func MergeSecurityConfigs(configs ...*SecurityConfig) *SecurityConfig {
1✔
492
        if len(configs) == 0 {
1✔
493
                return &SecurityConfig{
×
494
                        Source:  "none",
×
495
                        Version: 1,
×
496
                }
×
497
        }
×
498

499
        // Create base configuration from first config
500
        merged := createBaseConfig(configs[0])
1✔
501

1✔
502
        // Apply subsequent configs with precedence rules
1✔
503
        for i := 1; i < len(configs); i++ {
2✔
504
                mergeConfigOverrides(merged, configs[i])
1✔
505
        }
1✔
506

507
        return merged
1✔
508
}
509

510
// GetSecurityConfigPrecedenceInfo returns detailed information about the effective configuration precedence
511
// loadFileBasedConfigs discovers and loads file-based security configurations
512
func loadFileBasedConfigs() (map[string]*SecurityConfig, error) {
4✔
513
        sources := make(map[string]*SecurityConfig)
4✔
514

4✔
515
        configFiles, err := DiscoverSecurityConfigs()
4✔
516
        if err != nil {
4✔
517
                return nil, fmt.Errorf("failed to discover security config files: %w", err)
×
518
        }
×
519

520
        for _, configFile := range configFiles {
8✔
521
                if !configFile.Exists || !configFile.Readable {
4✔
522
                        continue
×
523
                }
524

525
                config, err := LoadSecurityConfigFromFile(configFile.Path)
4✔
526
                if err != nil {
4✔
527
                        continue
×
528
                }
529

530
                if config.IsSigned {
5✔
531
                        sources["signed"] = config
1✔
532
                        break // Use first signed config found
1✔
533
                } else {
3✔
534
                        if sources["unsigned"] == nil {
6✔
535
                                sources["unsigned"] = config
3✔
536
                        }
3✔
537
                }
538
        }
539

540
        return sources, nil
4✔
541
}
542

543
// createCLIConfig creates a security config from CLI arguments if any are provided
544
func createCLIConfig(cliAllowed []string, cliDenied []string, cliStrict bool) *SecurityConfig {
4✔
545
        if len(cliAllowed) > 0 || len(cliDenied) > 0 || cliStrict {
5✔
546
                return &SecurityConfig{
1✔
547
                        AllowedHosts: cliAllowed,
1✔
548
                        DeniedHosts:  cliDenied,
1✔
549
                        StrictMode:   cliStrict,
1✔
550
                        Source:       "cli",
1✔
551
                        Version:      1,
1✔
552
                }
1✔
553
        }
1✔
554
        return nil
3✔
555
}
556

557
// determineEffectiveConfig applies precedence rules to determine the active config
558
func determineEffectiveConfig(sources map[string]*SecurityConfig) (*SecurityConfig, string) {
4✔
559
        // Apply precedence rules: signed > cli > env > unsigned > none
4✔
560
        if sources["signed"] != nil {
5✔
561
                return sources["signed"], "signed"
1✔
562
        }
1✔
563
        if sources["cli"] != nil {
4✔
564
                return sources["cli"], "cli"
1✔
565
        }
1✔
566
        if sources["env"] != nil {
3✔
567
                return sources["env"], "env"
1✔
568
        }
1✔
569
        if sources["unsigned"] != nil {
2✔
570
                return sources["unsigned"], "unsigned"
1✔
571
        }
1✔
572

573
        // Default fallback
NEW
574
        return &SecurityConfig{
×
NEW
575
                Source:  "none",
×
NEW
576
                Version: 1,
×
NEW
577
        }, "none"
×
578
}
579

580
func GetSecurityConfigPrecedenceInfo(cliAllowed []string, cliDenied []string, cliStrict bool) (*SecurityConfigPrecedenceInfo, error) {
4✔
581
        info := &SecurityConfigPrecedenceInfo{
4✔
582
                Sources: make(map[string]*SecurityConfig),
4✔
583
        }
4✔
584

4✔
585
        // Load file-based configurations
4✔
586
        fileSources, err := loadFileBasedConfigs()
4✔
587
        if err != nil {
4✔
NEW
588
                return nil, err
×
NEW
589
        }
×
590

591
        // Merge file sources into info
592
        for key, config := range fileSources {
8✔
593
                info.Sources[key] = config
4✔
594
        }
4✔
595

596
        // Add CLI config if provided
597
        if cliConfig := createCLIConfig(cliAllowed, cliDenied, cliStrict); cliConfig != nil {
5✔
598
                info.Sources["cli"] = cliConfig
1✔
599
        }
1✔
600

601
        // Add environment config if available
602
        envConfig := LoadSecurityConfigFromEnv()
4✔
603
        if envConfig.Source != "none" {
5✔
604
                info.Sources["env"] = envConfig
1✔
605
        }
1✔
606

607
        // Determine effective configuration
608
        info.EffectiveConfig, info.EffectiveSource = determineEffectiveConfig(info.Sources)
4✔
609

4✔
610
        return info, nil
4✔
611
}
612

613
// SecurityConfigPrecedenceInfo provides detailed information about configuration precedence
614
type SecurityConfigPrecedenceInfo struct {
615
        Sources         map[string]*SecurityConfig
616
        EffectiveConfig *SecurityConfig
617
        EffectiveSource string
618
} // LoadSecurityConfigFromEnv loads security configuration from environment variables.
619
// Environment variables used:
620
//   - DUCK_ALLOWED_HOSTS: comma-separated list of allowed hostnames
621
//   - DUCK_DENIED_HOSTS: comma-separated list of denied hostnames
622
//   - DUCK_STRICT_MODE: "true" to fail if no restrictions are configured
623
func LoadSecurityConfigFromEnv() *SecurityConfig {
20✔
624
        cfg := &SecurityConfig{
20✔
625
                Source:  "none",
20✔
626
                Version: 1,
20✔
627
        }
20✔
628

20✔
629
        // Parse DUCK_ALLOWED_HOSTS
20✔
630
        if allowedEnv := strings.TrimSpace(os.Getenv("DUCK_ALLOWED_HOSTS")); allowedEnv != "" {
27✔
631
                cfg.AllowedHosts = parseHostList(allowedEnv)
7✔
632
                cfg.Source = "env"
7✔
633
        }
7✔
634

635
        // Parse DUCK_DENIED_HOSTS
636
        if deniedEnv := strings.TrimSpace(os.Getenv("DUCK_DENIED_HOSTS")); deniedEnv != "" {
23✔
637
                cfg.DeniedHosts = parseHostList(deniedEnv)
3✔
638
                cfg.Source = "env"
3✔
639
        }
3✔
640

641
        // Parse DUCK_STRICT_MODE
642
        if strictEnv := strings.TrimSpace(os.Getenv("DUCK_STRICT_MODE")); strictEnv != "" {
26✔
643
                cfg.StrictMode = strings.ToLower(strictEnv) == "true"
6✔
644
                if cfg.Source == "none" {
9✔
645
                        cfg.Source = "env"
3✔
646
                }
3✔
647
        }
648

649
        return cfg
20✔
650
}
651

652
// BuildSecurityConfig creates a security configuration with CLI flag precedence.
653
// CLI flags override environment variables. Only creates a CLI config when
654
// meaningful values are provided (non-empty slices or strict mode enabled).
655
func BuildSecurityConfig(cliAllowed []string, cliDenied []string, cliStrict bool) *SecurityConfig {
4✔
656
        // Only use CLI configuration if meaningful values are provided
4✔
657
        // (non-empty host lists or strict mode explicitly enabled)
4✔
658
        if len(cliAllowed) > 0 || len(cliDenied) > 0 || cliStrict {
6✔
659
                return &SecurityConfig{
2✔
660
                        AllowedHosts: cliAllowed,
2✔
661
                        DeniedHosts:  cliDenied,
2✔
662
                        StrictMode:   cliStrict,
2✔
663
                        Source:       "cli",
2✔
664
                        Version:      1, // Current configuration version
2✔
665
                }
2✔
666
        }
2✔
667

668
        // No meaningful CLI flags provided - fallback to environment variables
669
        envConfig := LoadSecurityConfigFromEnv()
2✔
670
        envConfig.Version = 1 // Ensure version is set
2✔
671
        return envConfig
2✔
672
}
673

674
// ValidateRepoAccess validates that a repository URL is allowed by the security policy.
675
// Returns an error if the repository host is denied or not in the allow list.
676
func ValidateRepoAccess(repoURL string, securityCfg *SecurityConfig) error {
49✔
677
        if securityCfg == nil {
49✔
678
                return fmt.Errorf("security configuration is nil")
×
679
        }
×
680

681
        // Extract hostname from repository URL
682
        host, err := extractHostFromGitURL(repoURL)
49✔
683
        if err != nil {
54✔
684
                return fmt.Errorf("failed to parse repository URL %q: %w", repoURL, err)
5✔
685
        }
5✔
686

687
        // In strict mode, fail if no restrictions are configured
688
        if securityCfg.StrictMode && len(securityCfg.AllowedHosts) == 0 && len(securityCfg.DeniedHosts) == 0 {
45✔
689
                return fmt.Errorf("strict mode enabled but no host restrictions configured (source: %s)", securityCfg.Source)
1✔
690
        }
1✔
691

692
        // Deny list takes precedence - check first
693
        for _, denied := range securityCfg.DeniedHosts {
56✔
694
                if matchHost(host, denied) {
18✔
695
                        return fmt.Errorf("repository host %q is explicitly denied (source: %s, denied hosts: %v)",
5✔
696
                                host, securityCfg.Source, securityCfg.DeniedHosts)
5✔
697
                }
5✔
698
        }
699

700
        // If allow list is configured, host must be in it
701
        if len(securityCfg.AllowedHosts) > 0 {
74✔
702
                for _, allowed := range securityCfg.AllowedHosts {
73✔
703
                        if matchHost(host, allowed) {
58✔
704
                                return nil // Explicitly allowed
21✔
705
                        }
21✔
706
                }
707
                return fmt.Errorf("repository host %q is not in allowed hosts %v (source: %s)",
15✔
708
                        host, securityCfg.AllowedHosts, securityCfg.Source)
15✔
709
        }
710

711
        // No restrictions configured and not in strict mode - allow all
712
        return nil
2✔
713
}
714

715
// extractHostFromGitURL extracts the hostname from various Git URL formats:
716
//   - HTTPS: https://github.com/user/repo.git
717
//   - SSH: git@github.com:user/repo.git
718
//   - SSH with port: ssh://git@github.com:22/user/repo.git
719
//   - For testing: simple hostnames like "stub", "local", etc. are returned as-is
720
//
721
// parseHTTPSURL extracts hostname from HTTPS/HTTP URLs
722
func parseHTTPSURL(repoURL string) (string, error) {
31✔
723
        u, err := url.Parse(repoURL)
31✔
724
        if err != nil {
31✔
NEW
725
                return "", fmt.Errorf("invalid HTTP(S) URL: %w", err)
×
NEW
726
        }
×
727
        if u.Host == "" {
32✔
728
                return "", fmt.Errorf("no host in HTTP(S) URL")
1✔
729
        }
1✔
730
        return u.Host, nil
30✔
731
}
732

733
// parseSSHURL extracts hostname from ssh:// URLs
734
func parseSSHURL(repoURL string) (string, error) {
4✔
735
        u, err := url.Parse(repoURL)
4✔
736
        if err != nil {
4✔
NEW
737
                return "", fmt.Errorf("invalid SSH URL: %w", err)
×
NEW
738
        }
×
739
        if u.Host == "" {
4✔
NEW
740
                return "", fmt.Errorf("no host in SSH URL")
×
NEW
741
        }
×
742
        return u.Host, nil
4✔
743
}
744

745
// parseSCPStyleURL extracts hostname from SCP-style SSH URLs (git@github.com:user/repo.git)
746
func parseSCPStyleURL(repoURL string) (string, error) {
7✔
747
        // Split on @ to get user and host:path parts
7✔
748
        parts := strings.SplitN(repoURL, "@", 2)
7✔
749
        if len(parts) != 2 {
7✔
NEW
750
                return "", fmt.Errorf("invalid SCP-style SSH URL format")
×
NEW
751
        }
×
752

753
        // Split host:path part on first colon
754
        hostPart := strings.SplitN(parts[1], ":", 2)
7✔
755
        if len(hostPart) != 2 {
7✔
NEW
756
                return "", fmt.Errorf("invalid SCP-style SSH URL format: missing colon after host")
×
NEW
757
        }
×
758

759
        host := strings.TrimSpace(hostPart[0])
7✔
760
        if host == "" {
7✔
NEW
761
                return "", fmt.Errorf("empty hostname in SCP-style SSH URL")
×
NEW
762
        }
×
763

764
        return host, nil
7✔
765
}
766

767
func extractHostFromGitURL(repoURL string) (string, error) {
49✔
768
        repoURL = strings.TrimSpace(repoURL)
49✔
769
        if repoURL == "" {
51✔
770
                return "", fmt.Errorf("empty repository URL")
2✔
771
        }
2✔
772

773
        // For testing: allow simple hostnames/stub URLs
774
        if !strings.Contains(repoURL, "://") && !strings.Contains(repoURL, "@") {
50✔
775
                // Simple hostname like "stub", "local", "r1", etc. used in tests
3✔
776
                return repoURL, nil
3✔
777
        }
3✔
778

779
        // Handle HTTPS/HTTP URLs
780
        if strings.HasPrefix(repoURL, "https://") || strings.HasPrefix(repoURL, "http://") {
75✔
781
                return parseHTTPSURL(repoURL)
31✔
782
        }
31✔
783

784
        // Handle SSH URLs with ssh:// scheme
785
        if strings.HasPrefix(repoURL, "ssh://") {
17✔
786
                return parseSSHURL(repoURL)
4✔
787
        }
4✔
788

789
        // Handle SCP-style SSH URLs: git@github.com:user/repo.git
790
        if strings.Contains(repoURL, "@") && strings.Contains(repoURL, ":") {
16✔
791
                return parseSCPStyleURL(repoURL)
7✔
792
        }
7✔
793

794
        return "", fmt.Errorf("unsupported URL format: must be HTTPS, HTTP, ssh://, or SCP-style SSH")
2✔
795
} // matchHost checks if a hostname matches a pattern.
796
// For now, only exact matches are supported. Future versions could add wildcard support.
797
func matchHost(host, pattern string) bool {
50✔
798
        return strings.EqualFold(strings.TrimSpace(host), strings.TrimSpace(pattern))
50✔
799
}
50✔
800

801
// parseHostList parses a comma-separated list of hostnames, trimming whitespace.
802
func parseHostList(hostList string) []string {
10✔
803
        if hostList == "" {
10✔
804
                return nil
×
805
        }
×
806

807
        hosts := strings.Split(hostList, ",")
10✔
808
        result := make([]string, 0, len(hosts))
10✔
809

10✔
810
        for _, host := range hosts {
28✔
811
                if trimmed := strings.TrimSpace(host); trimmed != "" {
36✔
812
                        result = append(result, trimmed)
18✔
813
                }
18✔
814
        }
815

816
        return result
10✔
817
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc