Skip to main content

mockforge_plugin_registry/
security.rs

1//! Plugin security scanning and validation
2
3use crate::Result;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7/// Security scan result
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ScanResult {
10    /// Overall status
11    pub status: ScanStatus,
12
13    /// Security score (0-100)
14    pub score: u8,
15
16    /// Findings by severity
17    pub findings: Vec<Finding>,
18
19    /// Scan metadata
20    pub metadata: ScanMetadata,
21}
22
23/// Scan status
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "lowercase")]
26pub enum ScanStatus {
27    Pass,
28    Warning,
29    Fail,
30}
31
32/// Security finding
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Finding {
35    /// Finding ID
36    pub id: String,
37
38    /// Severity level
39    pub severity: Severity,
40
41    /// Finding category
42    pub category: Category,
43
44    /// Title
45    pub title: String,
46
47    /// Description
48    pub description: String,
49
50    /// Location (file path, line number, etc.)
51    pub location: Option<String>,
52
53    /// Recommendation
54    pub recommendation: String,
55
56    /// References (CVE, CWE, etc.)
57    pub references: Vec<String>,
58}
59
60/// Severity level
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
62#[serde(rename_all = "lowercase")]
63pub enum Severity {
64    Info,
65    Low,
66    Medium,
67    High,
68    Critical,
69}
70
71/// Finding category
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum Category {
75    Malware,
76    VulnerableDependency,
77    InsecureCoding,
78    DataExfiltration,
79    SupplyChain,
80    Licensing,
81    Configuration,
82    Obfuscation,
83    Other,
84}
85
86/// Scan metadata
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ScanMetadata {
89    pub scan_id: String,
90    pub scanner_version: String,
91    pub scan_started_at: String,
92    pub scan_completed_at: String,
93    pub duration_ms: u64,
94    pub scanned_files: u32,
95    pub scanned_bytes: u64,
96}
97
98/// Security scanner configuration
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ScannerConfig {
101    /// Enable malware detection
102    pub enable_malware_scan: bool,
103
104    /// Enable dependency vulnerability scanning
105    pub enable_dependency_scan: bool,
106
107    /// Enable static code analysis
108    pub enable_static_analysis: bool,
109
110    /// Enable license compliance check
111    pub enable_license_check: bool,
112
113    /// Maximum file size to scan (bytes)
114    pub max_file_size: u64,
115
116    /// Timeout per file (seconds)
117    pub timeout_per_file: u64,
118
119    /// Allowed licenses
120    pub allowed_licenses: Vec<String>,
121
122    /// Severity threshold for failure
123    pub fail_on_severity: Severity,
124}
125
126impl Default for ScannerConfig {
127    fn default() -> Self {
128        Self {
129            enable_malware_scan: true,
130            enable_dependency_scan: true,
131            enable_static_analysis: true,
132            enable_license_check: true,
133            max_file_size: 10 * 1024 * 1024, // 10MB
134            timeout_per_file: 30,
135            allowed_licenses: vec![
136                "MIT".to_string(),
137                "Apache-2.0".to_string(),
138                "BSD-2-Clause".to_string(),
139                "BSD-3-Clause".to_string(),
140                "ISC".to_string(),
141                "MPL-2.0".to_string(),
142            ],
143            fail_on_severity: Severity::High,
144        }
145    }
146}
147
148/// Security scanner
149pub struct SecurityScanner {
150    config: ScannerConfig,
151}
152
153impl SecurityScanner {
154    /// Create a new scanner with config
155    pub fn new(config: ScannerConfig) -> Self {
156        Self { config }
157    }
158
159    /// Scan a plugin package
160    pub async fn scan_plugin(&self, package_path: &Path) -> Result<ScanResult> {
161        let start_time = std::time::Instant::now();
162        let scan_id = uuid::Uuid::new_v4().to_string();
163
164        let mut findings = Vec::new();
165        let scanned_files = 0;
166        let scanned_bytes = 0;
167
168        // 1. Malware detection
169        if self.config.enable_malware_scan {
170            findings.extend(self.scan_for_malware(package_path).await?);
171        }
172
173        // 2. Dependency vulnerability scan
174        if self.config.enable_dependency_scan {
175            findings.extend(self.scan_dependencies(package_path).await?);
176        }
177
178        // 3. Static code analysis
179        if self.config.enable_static_analysis {
180            findings.extend(self.static_analysis(package_path).await?);
181        }
182
183        // 4. License compliance
184        if self.config.enable_license_check {
185            findings.extend(self.check_license_compliance(package_path).await?);
186        }
187
188        // Calculate score and status
189        let score = self.calculate_security_score(&findings);
190        let status = self.determine_status(&findings);
191
192        let duration = start_time.elapsed();
193
194        Ok(ScanResult {
195            status,
196            score,
197            findings,
198            metadata: ScanMetadata {
199                scan_id,
200                scanner_version: env!("CARGO_PKG_VERSION").to_string(),
201                scan_started_at: chrono::Utc::now().to_rfc3339(),
202                scan_completed_at: chrono::Utc::now().to_rfc3339(),
203                duration_ms: duration.as_millis() as u64,
204                scanned_files,
205                scanned_bytes,
206            },
207        })
208    }
209
210    async fn scan_for_malware(&self, package_path: &Path) -> Result<Vec<Finding>> {
211        let mut findings = Vec::new();
212
213        let suspicious_patterns = [
214            "backdoor",
215            "keylogger",
216            "trojan",
217            "ransomware",
218            "cryptominer",
219            "rootkit",
220            "exploit",
221        ];
222
223        Self::walk_files(package_path, self.config.max_file_size, &mut |path| {
224            let file_name =
225                path.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
226
227            for pattern in &suspicious_patterns {
228                if file_name.contains(pattern) {
229                    findings.push(Finding {
230                        id: format!("MAL-FILENAME-{}", uuid::Uuid::new_v4()),
231                        severity: Severity::High,
232                        category: Category::Malware,
233                        title: format!("Suspicious file name: {}", file_name),
234                        description: format!(
235                            "File name contains suspicious pattern '{}'. This may indicate malicious content.",
236                            pattern
237                        ),
238                        location: Some(path.display().to_string()),
239                        recommendation: "Review the file contents and remove if malicious.".to_string(),
240                        references: vec!["CWE-506: Embedded Malicious Code".to_string()],
241                    });
242                }
243            }
244        });
245
246        Ok(findings)
247    }
248
249    async fn scan_dependencies(&self, package_path: &Path) -> Result<Vec<Finding>> {
250        let mut findings = Vec::new();
251
252        // Check Cargo.toml for suspicious dependency patterns
253        let cargo_toml_path = package_path.join("Cargo.toml");
254        if cargo_toml_path.exists() {
255            if let Ok(content) = std::fs::read_to_string(&cargo_toml_path) {
256                // Check for git dependencies pointing to non-standard registries
257                for line in content.lines() {
258                    let trimmed = line.trim();
259                    if trimmed.contains("git = \"")
260                        && !trimmed.contains("github.com")
261                        && !trimmed.contains("gitlab.com")
262                    {
263                        findings.push(Finding {
264                            id: format!("DEP-GIT-{}", uuid::Uuid::new_v4()),
265                            severity: Severity::Medium,
266                            category: Category::SupplyChain,
267                            title: "Non-standard git dependency source".to_string(),
268                            description: format!(
269                                "Dependency uses a non-standard git repository: {}",
270                                trimmed
271                            ),
272                            location: Some(cargo_toml_path.display().to_string()),
273                            recommendation: "Verify the dependency source is trusted.".to_string(),
274                            references: vec![
275                                "CWE-829: Inclusion of Functionality from Untrusted Control Sphere"
276                                    .to_string(),
277                            ],
278                        });
279                    }
280                    // Check for path dependencies that escape the package
281                    if trimmed.contains("path = \"") && trimmed.contains("..") {
282                        findings.push(Finding {
283                            id: format!("DEP-PATH-{}", uuid::Uuid::new_v4()),
284                            severity: Severity::Low,
285                            category: Category::SupplyChain,
286                            title: "Path dependency with parent traversal".to_string(),
287                            description: format!(
288                                "Dependency uses a relative path that traverses parent directories: {}",
289                                trimmed
290                            ),
291                            location: Some(cargo_toml_path.display().to_string()),
292                            recommendation: "Ensure path dependencies don't reference files outside the package.".to_string(),
293                            references: vec![],
294                        });
295                    }
296                }
297            }
298        }
299
300        // Check package.json for npm dependency issues
301        let package_json_path = package_path.join("package.json");
302        if package_json_path.exists() {
303            if let Ok(content) = std::fs::read_to_string(&package_json_path) {
304                // Check for install scripts (common vector for supply chain attacks)
305                if content.contains("\"preinstall\"") || content.contains("\"postinstall\"") {
306                    findings.push(Finding {
307                        id: format!("DEP-SCRIPT-{}", uuid::Uuid::new_v4()),
308                        severity: Severity::Medium,
309                        category: Category::SupplyChain,
310                        title: "Package contains install scripts".to_string(),
311                        description: "Package defines preinstall or postinstall scripts which can execute arbitrary code during installation.".to_string(),
312                        location: Some(package_json_path.display().to_string()),
313                        recommendation: "Review install scripts for malicious behavior.".to_string(),
314                        references: vec!["CWE-829: Inclusion of Functionality from Untrusted Control Sphere".to_string()],
315                    });
316                }
317            }
318        }
319
320        Ok(findings)
321    }
322
323    async fn static_analysis(&self, package_path: &Path) -> Result<Vec<Finding>> {
324        let mut findings = Vec::new();
325
326        let secret_patterns = [
327            ("password", "Hardcoded password"),
328            ("secret_key", "Hardcoded secret key"),
329            ("api_key", "Hardcoded API key"),
330            ("private_key", "Hardcoded private key"),
331            ("access_token", "Hardcoded access token"),
332        ];
333
334        Self::walk_files(package_path, self.config.max_file_size, &mut |path| {
335            let ext =
336                path.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
337
338            // Only scan source code files
339            if !matches!(ext.as_str(), "rs" | "js" | "ts" | "py" | "go" | "java" | "rb" | "sh") {
340                return;
341            }
342
343            let Ok(content) = std::fs::read_to_string(path) else {
344                return;
345            };
346
347            // Check for unsafe blocks in Rust
348            if ext == "rs" && content.contains("unsafe {") {
349                let unsafe_count = content.matches("unsafe {").count();
350                findings.push(Finding {
351                    id: format!("SA-UNSAFE-{}", uuid::Uuid::new_v4()),
352                    severity: Severity::Medium,
353                    category: Category::InsecureCoding,
354                    title: format!("Contains {} unsafe block(s)", unsafe_count),
355                    description: "Unsafe code can lead to memory safety issues. Each unsafe block should be carefully reviewed.".to_string(),
356                    location: Some(path.display().to_string()),
357                    recommendation: "Ensure all unsafe blocks have SAFETY comments and are truly necessary.".to_string(),
358                    references: vec!["CWE-119: Buffer Overflow".to_string()],
359                });
360            }
361
362            // Check for hardcoded credentials
363            for (pattern, description) in &secret_patterns {
364                for (line_num, line) in content.lines().enumerate() {
365                    let lower = line.to_lowercase();
366                    // Look for assignment patterns like: password = "...", api_key: "..."
367                    if lower.contains(pattern)
368                        && (line.contains("= \"") || line.contains(": \"") || line.contains("=\""))
369                        && !line.trim_start().starts_with("//")
370                        && !line.trim_start().starts_with('#')
371                        && !line.trim_start().starts_with("///")
372                    {
373                        findings.push(Finding {
374                            id: format!("SA-SECRET-{}", uuid::Uuid::new_v4()),
375                            severity: Severity::High,
376                            category: Category::DataExfiltration,
377                            title: format!("{} detected", description),
378                            description: format!(
379                                "Possible hardcoded credential at line {}. Secrets should be loaded from environment variables or a secret manager.",
380                                line_num + 1
381                            ),
382                            location: Some(format!("{}:{}", path.display(), line_num + 1)),
383                            recommendation: "Move credentials to environment variables or a secrets manager.".to_string(),
384                            references: vec!["CWE-798: Use of Hard-coded Credentials".to_string()],
385                        });
386                        break; // Only report once per pattern per file
387                    }
388                }
389            }
390
391            // Check for command injection patterns
392            if ext == "rs" && (content.contains("Command::new") && content.contains(".arg(")) {
393                // Only flag if user input might flow in (heuristic: function parameters used in Command)
394                if content.contains("std::process::Command") {
395                    findings.push(Finding {
396                        id: format!("SA-CMDINJ-{}", uuid::Uuid::new_v4()),
397                        severity: Severity::Low,
398                        category: Category::InsecureCoding,
399                        title: "External command execution detected".to_string(),
400                        description: "Code executes external commands. Ensure arguments are properly sanitized.".to_string(),
401                        location: Some(path.display().to_string()),
402                        recommendation: "Validate and sanitize all inputs passed to external commands.".to_string(),
403                        references: vec!["CWE-78: OS Command Injection".to_string()],
404                    });
405                }
406            }
407        });
408
409        Ok(findings)
410    }
411
412    async fn check_license_compliance(&self, package_path: &Path) -> Result<Vec<Finding>> {
413        let mut findings = Vec::new();
414
415        // Check for license file presence
416        let has_license = package_path.join("LICENSE").exists()
417            || package_path.join("LICENSE.md").exists()
418            || package_path.join("LICENSE.txt").exists()
419            || package_path.join("LICENCE").exists();
420
421        if !has_license {
422            findings.push(Finding {
423                id: format!("LIC-MISSING-{}", uuid::Uuid::new_v4()),
424                severity: Severity::Medium,
425                category: Category::Licensing,
426                title: "No LICENSE file found".to_string(),
427                description:
428                    "Package does not contain a LICENSE file. License must be clearly specified."
429                        .to_string(),
430                location: Some(package_path.display().to_string()),
431                recommendation: "Add a LICENSE file with an approved open source license."
432                    .to_string(),
433                references: vec![],
434            });
435        }
436
437        // Check Cargo.toml for license field
438        let cargo_toml_path = package_path.join("Cargo.toml");
439        if cargo_toml_path.exists() {
440            if let Ok(content) = std::fs::read_to_string(&cargo_toml_path) {
441                let has_license_field = content.lines().any(|line| {
442                    let trimmed = line.trim();
443                    trimmed.starts_with("license ")
444                        || trimmed.starts_with("license=")
445                        || trimmed.starts_with("license-file")
446                });
447
448                if !has_license_field {
449                    findings.push(Finding {
450                        id: format!("LIC-CARGO-{}", uuid::Uuid::new_v4()),
451                        severity: Severity::Low,
452                        category: Category::Licensing,
453                        title: "No license field in Cargo.toml".to_string(),
454                        description: "Cargo.toml does not specify a license or license-file field."
455                            .to_string(),
456                        location: Some(cargo_toml_path.display().to_string()),
457                        recommendation:
458                            "Add a 'license' field to Cargo.toml with an SPDX identifier."
459                                .to_string(),
460                        references: vec![],
461                    });
462                } else {
463                    // Check if license is in allowed list
464                    for line in content.lines() {
465                        let trimmed = line.trim();
466                        if (trimmed.starts_with("license ") || trimmed.starts_with("license="))
467                            && !trimmed.starts_with("license-file")
468                        {
469                            let license_value = trimmed
470                                .split('=')
471                                .nth(1)
472                                .unwrap_or("")
473                                .trim()
474                                .trim_matches('"')
475                                .trim_matches('\'');
476
477                            // Check each license in an OR expression
478                            let all_allowed = license_value.split(" OR ").all(|l| {
479                                self.config.allowed_licenses.iter().any(|a| a == l.trim())
480                            });
481
482                            if !all_allowed && !license_value.is_empty() {
483                                findings.push(Finding {
484                                    id: format!("LIC-UNAPPROVED-{}", uuid::Uuid::new_v4()),
485                                    severity: Severity::Medium,
486                                    category: Category::Licensing,
487                                    title: format!("License '{}' may not be approved", license_value),
488                                    description: format!(
489                                        "The license '{}' is not in the approved license list: {:?}",
490                                        license_value, self.config.allowed_licenses
491                                    ),
492                                    location: Some(cargo_toml_path.display().to_string()),
493                                    recommendation: "Use an approved license or request an exception.".to_string(),
494                                    references: vec![],
495                                });
496                            }
497                        }
498                    }
499                }
500            }
501        }
502
503        Ok(findings)
504    }
505
506    /// Walk files in a directory, calling the callback for each file within the size limit.
507    fn walk_files(dir: &Path, max_size: u64, callback: &mut dyn FnMut(&Path)) {
508        let mut stack = vec![dir.to_path_buf()];
509        while let Some(current) = stack.pop() {
510            let Ok(entries) = std::fs::read_dir(&current) else {
511                continue;
512            };
513            for entry in entries.flatten() {
514                let path = entry.path();
515                if path.is_dir() {
516                    stack.push(path);
517                } else if let Ok(meta) = std::fs::metadata(&path) {
518                    if meta.len() <= max_size {
519                        callback(&path);
520                    }
521                }
522            }
523        }
524    }
525
526    fn calculate_security_score(&self, findings: &[Finding]) -> u8 {
527        let mut score: u8 = 100;
528
529        for finding in findings {
530            let deduction = match finding.severity {
531                Severity::Critical => 30,
532                Severity::High => 20,
533                Severity::Medium => 10,
534                Severity::Low => 5,
535                Severity::Info => 0,
536            };
537            score = score.saturating_sub(deduction);
538        }
539
540        score
541    }
542
543    fn determine_status(&self, findings: &[Finding]) -> ScanStatus {
544        let has_critical = findings.iter().any(|f| f.severity >= self.config.fail_on_severity);
545
546        if has_critical {
547            ScanStatus::Fail
548        } else if findings.iter().any(|f| f.severity >= Severity::Medium) {
549            ScanStatus::Warning
550        } else {
551            ScanStatus::Pass
552        }
553    }
554}
555
556impl Default for SecurityScanner {
557    fn default() -> Self {
558        Self::new(ScannerConfig::default())
559    }
560}
561
562/// Vulnerability database entry
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct Vulnerability {
565    pub id: String,
566    pub package: String,
567    pub versions: Vec<String>,
568    pub severity: Severity,
569    pub title: String,
570    pub description: String,
571    pub cvss_score: Option<f32>,
572    pub cve: Option<String>,
573    pub patched_versions: Vec<String>,
574    pub references: Vec<String>,
575}
576
577/// License information
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct LicenseInfo {
580    pub spdx_id: String,
581    pub name: String,
582    pub approved: bool,
583    pub osi_approved: bool,
584    pub category: LicenseCategory,
585}
586
587/// License category
588#[derive(Debug, Clone, Serialize, Deserialize)]
589#[serde(rename_all = "lowercase")]
590pub enum LicenseCategory {
591    Permissive,
592    Copyleft,
593    Proprietary,
594    Unknown,
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_security_score_calculation() {
603        let scanner = SecurityScanner::default();
604
605        let findings = vec![
606            Finding {
607                id: "1".to_string(),
608                severity: Severity::High,
609                category: Category::Malware,
610                title: "Suspicious code".to_string(),
611                description: "Test".to_string(),
612                location: None,
613                recommendation: "Remove".to_string(),
614                references: vec![],
615            },
616            Finding {
617                id: "2".to_string(),
618                severity: Severity::Medium,
619                category: Category::InsecureCoding,
620                title: "Weak encryption".to_string(),
621                description: "Test".to_string(),
622                location: None,
623                recommendation: "Use strong encryption".to_string(),
624                references: vec![],
625            },
626        ];
627
628        let score = scanner.calculate_security_score(&findings);
629        assert_eq!(score, 70); // 100 - 20 - 10
630    }
631
632    #[test]
633    fn test_status_determination() {
634        let scanner = SecurityScanner::default();
635
636        let critical_findings = vec![Finding {
637            id: "1".to_string(),
638            severity: Severity::Critical,
639            category: Category::Malware,
640            title: "Malware detected".to_string(),
641            description: "Test".to_string(),
642            location: None,
643            recommendation: "Remove".to_string(),
644            references: vec![],
645        }];
646
647        assert_eq!(scanner.determine_status(&critical_findings), ScanStatus::Fail);
648
649        let medium_findings = vec![Finding {
650            id: "1".to_string(),
651            severity: Severity::Medium,
652            category: Category::InsecureCoding,
653            title: "Code issue".to_string(),
654            description: "Test".to_string(),
655            location: None,
656            recommendation: "Fix".to_string(),
657            references: vec![],
658        }];
659
660        assert_eq!(scanner.determine_status(&medium_findings), ScanStatus::Warning);
661
662        let low_findings = vec![Finding {
663            id: "1".to_string(),
664            severity: Severity::Low,
665            category: Category::Configuration,
666            title: "Config issue".to_string(),
667            description: "Test".to_string(),
668            location: None,
669            recommendation: "Update".to_string(),
670            references: vec![],
671        }];
672
673        assert_eq!(scanner.determine_status(&low_findings), ScanStatus::Pass);
674    }
675}