Skip to main content

plugin_packager/
security.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Plugin vulnerability scanning and security auditing
5///
6/// This module provides capabilities for:
7/// - Scanning plugins for known vulnerabilities
8/// - License compliance checking
9/// - Supply chain security audit
10/// - CVE detection and reporting
11/// - Dependency vulnerability analysis
12use serde::{Deserialize, Serialize};
13
14/// Vulnerability severity levels
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum VulnerabilitySeverity {
18    Informational,
19    Low,
20    Medium,
21    High,
22    Critical,
23}
24
25impl VulnerabilitySeverity {
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            VulnerabilitySeverity::Informational => "informational",
29            VulnerabilitySeverity::Low => "low",
30            VulnerabilitySeverity::Medium => "medium",
31            VulnerabilitySeverity::High => "high",
32            VulnerabilitySeverity::Critical => "critical",
33        }
34    }
35
36    pub fn try_parse(s: &str) -> Option<Self> {
37        match s.to_lowercase().as_str() {
38            "informational" => Some(VulnerabilitySeverity::Informational),
39            "low" => Some(VulnerabilitySeverity::Low),
40            "medium" => Some(VulnerabilitySeverity::Medium),
41            "high" => Some(VulnerabilitySeverity::High),
42            "critical" => Some(VulnerabilitySeverity::Critical),
43            _ => None,
44        }
45    }
46}
47
48/// Detected vulnerability
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Vulnerability {
51    pub id: String, // CVE ID or internal identifier
52    pub title: String,
53    pub description: String,
54    pub severity: VulnerabilitySeverity,
55    pub affected_version: String,
56    pub fixed_version: Option<String>,
57    pub advisory_url: Option<String>,
58    pub published_date: String,
59    pub discovered_at: String,
60}
61
62/// License type for compliance checking
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub enum LicenseType {
65    MIT,
66    Apache2,
67    GPL2,
68    GPL3,
69    BSD,
70    ISC,
71    MPL2,
72    Custom(String),
73    Unknown,
74}
75
76impl LicenseType {
77    pub fn from_spdx(spdx_id: &str) -> Self {
78        match spdx_id.to_uppercase().as_str() {
79            "MIT" => LicenseType::MIT,
80            "APACHE-2.0" => LicenseType::Apache2,
81            "GPL-2.0" => LicenseType::GPL2,
82            "GPL-3.0" => LicenseType::GPL3,
83            "BSD-2-CLAUSE" | "BSD-3-CLAUSE" => LicenseType::BSD,
84            "ISC" => LicenseType::ISC,
85            "MPL-2.0" => LicenseType::MPL2,
86            _ => LicenseType::Custom(spdx_id.to_string()),
87        }
88    }
89
90    pub fn is_permissive(&self) -> bool {
91        matches!(
92            self,
93            LicenseType::MIT | LicenseType::Apache2 | LicenseType::BSD | LicenseType::ISC
94        )
95    }
96
97    pub fn is_copyleft(&self) -> bool {
98        matches!(
99            self,
100            LicenseType::GPL2 | LicenseType::GPL3 | LicenseType::MPL2
101        )
102    }
103}
104
105/// License compliance issue
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LicenseCompliance {
108    pub dependency: String,
109    pub version: String,
110    pub license: LicenseType,
111    pub is_approved: bool,
112    pub issue: Option<String>,
113}
114
115/// Scan result for a single plugin
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SecurityScanResult {
118    pub plugin_id: String,
119    pub plugin_version: String,
120    pub scan_timestamp: String,
121    pub vulnerabilities: Vec<Vulnerability>,
122    pub license_issues: Vec<LicenseCompliance>,
123    pub dependency_count: usize,
124    pub vulnerable_dependency_count: usize,
125    pub high_severity_count: usize,
126    pub critical_severity_count: usize,
127    pub overall_risk: RiskLevel,
128}
129
130/// Overall risk assessment
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
132#[serde(rename_all = "lowercase")]
133pub enum RiskLevel {
134    Safe,
135    Low,
136    Medium,
137    High,
138    Critical,
139}
140
141impl RiskLevel {
142    pub fn description(&self) -> &'static str {
143        match self {
144            RiskLevel::Safe => "No vulnerabilities detected",
145            RiskLevel::Low => "Minor vulnerabilities, low risk",
146            RiskLevel::Medium => "Moderate vulnerabilities present",
147            RiskLevel::High => "Significant security concerns",
148            RiskLevel::Critical => "Critical vulnerabilities - do not use",
149        }
150    }
151}
152
153/// Vulnerability scanner
154pub struct VulnerabilityScanner {
155    known_vulnerabilities: Vec<Vulnerability>,
156    approved_licenses: Vec<LicenseType>,
157    max_allowed_severity: VulnerabilitySeverity,
158}
159
160impl VulnerabilityScanner {
161    /// Create a new vulnerability scanner
162    pub fn new() -> Self {
163        Self {
164            known_vulnerabilities: Vec::new(),
165            approved_licenses: vec![
166                LicenseType::MIT,
167                LicenseType::Apache2,
168                LicenseType::BSD,
169                LicenseType::ISC,
170            ],
171            max_allowed_severity: VulnerabilitySeverity::High,
172        }
173    }
174
175    /// Create scanner with custom severity threshold
176    pub fn with_severity_threshold(max_severity: VulnerabilitySeverity) -> Self {
177        let mut scanner = Self::new();
178        scanner.max_allowed_severity = max_severity;
179        scanner
180    }
181
182    /// Register a known vulnerability
183    pub fn register_vulnerability(&mut self, vuln: Vulnerability) {
184        self.known_vulnerabilities.push(vuln);
185    }
186
187    /// Add an approved license
188    pub fn approve_license(&mut self, license: LicenseType) {
189        if !self.approved_licenses.contains(&license) {
190            self.approved_licenses.push(license);
191        }
192    }
193
194    /// Scan a plugin for vulnerabilities
195    pub fn scan_plugin(
196        &self,
197        plugin_id: &str,
198        version: &str,
199        dependencies: Vec<(&str, &str)>, // (name, version)
200    ) -> SecurityScanResult {
201        let mut vulnerabilities = Vec::new();
202        let mut high_count = 0;
203        let mut critical_count = 0;
204
205        // Check for known vulnerabilities in dependencies
206        for (_dep_name, dep_version) in &dependencies {
207            for vuln in &self.known_vulnerabilities {
208                if vuln.affected_version == *dep_version {
209                    if vuln.severity >= VulnerabilitySeverity::High {
210                        high_count += 1;
211                    }
212                    if vuln.severity == VulnerabilitySeverity::Critical {
213                        critical_count += 1;
214                    }
215                    vulnerabilities.push(vuln.clone());
216                }
217            }
218        }
219
220        let vulnerable_dep_count = dependencies.len();
221        let risk_level = self.assess_risk_level(
222            vulnerabilities.len(),
223            high_count,
224            critical_count,
225            vulnerable_dep_count,
226        );
227
228        SecurityScanResult {
229            plugin_id: plugin_id.to_string(),
230            plugin_version: version.to_string(),
231            scan_timestamp: chrono::Utc::now().to_rfc3339(),
232            vulnerabilities,
233            license_issues: Vec::new(),
234            dependency_count: dependencies.len(),
235            vulnerable_dependency_count: vulnerable_dep_count,
236            high_severity_count: high_count,
237            critical_severity_count: critical_count,
238            overall_risk: risk_level,
239        }
240    }
241
242    /// Check license compliance for dependencies
243    pub fn check_license_compliance(
244        &self,
245        dependencies: Vec<(&str, &str, &str)>, // (name, version, license_spdx)
246    ) -> Vec<LicenseCompliance> {
247        dependencies
248            .into_iter()
249            .map(|(name, version, license_spdx)| {
250                let license_type = LicenseType::from_spdx(license_spdx);
251                let is_approved = self.approved_licenses.contains(&license_type);
252
253                LicenseCompliance {
254                    dependency: name.to_string(),
255                    version: version.to_string(),
256                    license: license_type,
257                    is_approved,
258                    issue: if !is_approved {
259                        Some(format!("License {} not approved", license_spdx))
260                    } else {
261                        None
262                    },
263                }
264            })
265            .collect()
266    }
267
268    /// Assess overall risk level
269    fn assess_risk_level(
270        &self,
271        total_vulns: usize,
272        high_count: usize,
273        critical_count: usize,
274        _dep_count: usize,
275    ) -> RiskLevel {
276        if critical_count > 0 {
277            RiskLevel::Critical
278        } else if high_count > 2 {
279            RiskLevel::High
280        } else if high_count > 0 {
281            RiskLevel::Medium
282        } else if total_vulns > 5 {
283            RiskLevel::Low
284        } else {
285            RiskLevel::Safe
286        }
287    }
288
289    /// Check if scan result is acceptable based on configuration
290    pub fn is_acceptable(&self, result: &SecurityScanResult) -> bool {
291        if result.critical_severity_count > 0 {
292            return false;
293        }
294
295        result.high_severity_count
296            <= (match self.max_allowed_severity {
297                VulnerabilitySeverity::Informational => 10,
298                VulnerabilitySeverity::Low => 5,
299                VulnerabilitySeverity::Medium => 2,
300                VulnerabilitySeverity::High => 1,
301                VulnerabilitySeverity::Critical => 0,
302            })
303    }
304}
305
306impl Default for VulnerabilityScanner {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312/// Audit report combining security scan and compliance
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct SecurityAuditReport {
315    pub plugin_id: String,
316    pub plugin_version: String,
317    pub report_timestamp: String,
318    pub scan_result: SecurityScanResult,
319    pub license_compliances: Vec<LicenseCompliance>,
320    pub recommendations: Vec<String>,
321    pub approved: bool,
322}
323
324impl SecurityAuditReport {
325    /// Generate recommendations based on scan results
326    pub fn generate_recommendations(&mut self) {
327        self.recommendations.clear();
328
329        // Vulnerability recommendations
330        if self.scan_result.critical_severity_count > 0 {
331            self.recommendations.push(
332                "CRITICAL: Do not publish. Address critical vulnerabilities immediately."
333                    .to_string(),
334            );
335        }
336
337        if self.scan_result.high_severity_count > 2 {
338            self.recommendations.push(
339                "HIGH RISK: Multiple high-severity vulnerabilities detected. Consider patching."
340                    .to_string(),
341            );
342        }
343
344        // License recommendations
345        let unapproved_licenses: Vec<_> = self
346            .license_compliances
347            .iter()
348            .filter(|lc| !lc.is_approved)
349            .collect();
350
351        if !unapproved_licenses.is_empty() {
352            self.recommendations.push(format!(
353                "LICENSE: {} unapproved license(s) found. Review and update.",
354                unapproved_licenses.len()
355            ));
356        }
357
358        // Dependency count recommendations
359        if self.scan_result.dependency_count > 50 {
360            self.recommendations.push(
361                "DEPENDENCY: High number of dependencies increases attack surface. Consider minimizing."
362                    .to_string(),
363            );
364        }
365
366        // Overall approval
367        self.approved = self.scan_result.critical_severity_count == 0
368            && unapproved_licenses.is_empty()
369            && self.scan_result.high_severity_count <= 1;
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_vulnerability_severity_ordering() {
379        assert!(VulnerabilitySeverity::Critical > VulnerabilitySeverity::High);
380        assert!(VulnerabilitySeverity::High > VulnerabilitySeverity::Medium);
381        assert!(VulnerabilitySeverity::Medium > VulnerabilitySeverity::Low);
382    }
383
384    #[test]
385    fn test_vulnerability_severity_to_str() {
386        assert_eq!(VulnerabilitySeverity::Critical.as_str(), "critical");
387        assert_eq!(VulnerabilitySeverity::Low.as_str(), "low");
388    }
389
390    #[test]
391    fn test_vulnerability_severity_try_parse() {
392        assert_eq!(
393            VulnerabilitySeverity::try_parse("critical"),
394            Some(VulnerabilitySeverity::Critical)
395        );
396        assert_eq!(VulnerabilitySeverity::try_parse("invalid"), None);
397    }
398
399    #[test]
400    fn test_license_type_permissive() {
401        assert!(LicenseType::MIT.is_permissive());
402        assert!(LicenseType::Apache2.is_permissive());
403        assert!(!LicenseType::GPL3.is_permissive());
404    }
405
406    #[test]
407    fn test_license_type_copyleft() {
408        assert!(LicenseType::GPL3.is_copyleft());
409        assert!(LicenseType::MPL2.is_copyleft());
410        assert!(!LicenseType::MIT.is_copyleft());
411    }
412
413    #[test]
414    fn test_license_from_spdx() {
415        assert_eq!(LicenseType::from_spdx("MIT"), LicenseType::MIT);
416        assert_eq!(LicenseType::from_spdx("Apache-2.0"), LicenseType::Apache2);
417        assert_eq!(LicenseType::from_spdx("GPL-3.0"), LicenseType::GPL3);
418    }
419
420    #[test]
421    fn test_scanner_creation() {
422        let scanner = VulnerabilityScanner::new();
423        assert_eq!(scanner.approved_licenses.len(), 4); // MIT, Apache2, BSD, ISC
424    }
425
426    #[test]
427    fn test_scanner_register_vulnerability() {
428        let mut scanner = VulnerabilityScanner::new();
429        let vuln = Vulnerability {
430            id: "CVE-2024-0001".to_string(),
431            title: "Test Vulnerability".to_string(),
432            description: "A test vulnerability".to_string(),
433            severity: VulnerabilitySeverity::High,
434            affected_version: "1.0.0".to_string(),
435            fixed_version: Some("1.0.1".to_string()),
436            advisory_url: Some("https://example.com".to_string()),
437            published_date: "2024-01-01".to_string(),
438            discovered_at: chrono::Utc::now().to_rfc3339(),
439        };
440
441        scanner.register_vulnerability(vuln);
442        assert_eq!(scanner.known_vulnerabilities.len(), 1);
443    }
444
445    #[test]
446    fn test_risk_level_ordering() {
447        assert!(RiskLevel::Critical > RiskLevel::High);
448        assert!(RiskLevel::High > RiskLevel::Medium);
449        assert!(RiskLevel::Safe < RiskLevel::Low);
450    }
451
452    #[test]
453    fn test_risk_level_descriptions() {
454        assert!(!RiskLevel::Safe.description().is_empty());
455        assert!(!RiskLevel::Critical.description().is_empty());
456    }
457
458    #[test]
459    fn test_scan_plugin_no_vulnerabilities() {
460        let scanner = VulnerabilityScanner::new();
461        let result = scanner.scan_plugin("test-plugin", "1.0.0", vec![]);
462
463        assert_eq!(result.critical_severity_count, 0);
464        assert_eq!(result.high_severity_count, 0);
465        assert_eq!(result.overall_risk, RiskLevel::Safe);
466    }
467
468    #[test]
469    fn test_scan_result_acceptable() {
470        let result = SecurityScanResult {
471            plugin_id: "test".to_string(),
472            plugin_version: "1.0.0".to_string(),
473            scan_timestamp: "2024-01-01".to_string(),
474            vulnerabilities: Vec::new(),
475            license_issues: Vec::new(),
476            dependency_count: 5,
477            vulnerable_dependency_count: 0,
478            high_severity_count: 0,
479            critical_severity_count: 0,
480            overall_risk: RiskLevel::Safe,
481        };
482
483        let scanner = VulnerabilityScanner::new();
484        assert!(scanner.is_acceptable(&result));
485    }
486
487    #[test]
488    fn test_license_compliance_check() {
489        let scanner = VulnerabilityScanner::new();
490        let deps = vec![("dep1", "1.0.0", "MIT"), ("dep2", "2.0.0", "GPL-3.0")];
491
492        let compliances = scanner.check_license_compliance(deps);
493        assert_eq!(compliances.len(), 2);
494        assert!(compliances[0].is_approved); // MIT is approved
495        assert!(!compliances[1].is_approved); // GPL-3.0 is not approved
496    }
497
498    #[test]
499    fn test_audit_report_recommendations() {
500        let mut report = SecurityAuditReport {
501            plugin_id: "test".to_string(),
502            plugin_version: "1.0.0".to_string(),
503            report_timestamp: chrono::Utc::now().to_rfc3339(),
504            scan_result: SecurityScanResult {
505                plugin_id: "test".to_string(),
506                plugin_version: "1.0.0".to_string(),
507                scan_timestamp: chrono::Utc::now().to_rfc3339(),
508                vulnerabilities: Vec::new(),
509                license_issues: Vec::new(),
510                dependency_count: 5,
511                vulnerable_dependency_count: 0,
512                high_severity_count: 0,
513                critical_severity_count: 0,
514                overall_risk: RiskLevel::Safe,
515            },
516            license_compliances: Vec::new(),
517            recommendations: Vec::new(),
518            approved: false,
519        };
520
521        report.generate_recommendations();
522        assert!(report.approved);
523    }
524
525    #[test]
526    fn test_audit_report_critical_vulnerability() {
527        let mut report = SecurityAuditReport {
528            plugin_id: "test".to_string(),
529            plugin_version: "1.0.0".to_string(),
530            report_timestamp: chrono::Utc::now().to_rfc3339(),
531            scan_result: SecurityScanResult {
532                plugin_id: "test".to_string(),
533                plugin_version: "1.0.0".to_string(),
534                scan_timestamp: chrono::Utc::now().to_rfc3339(),
535                vulnerabilities: Vec::new(),
536                license_issues: Vec::new(),
537                dependency_count: 5,
538                vulnerable_dependency_count: 0,
539                high_severity_count: 0,
540                critical_severity_count: 1, // Critical vulnerability
541                overall_risk: RiskLevel::Critical,
542            },
543            license_compliances: Vec::new(),
544            recommendations: Vec::new(),
545            approved: false,
546        };
547
548        report.generate_recommendations();
549        assert!(!report.approved);
550        assert!(!report.recommendations.is_empty());
551    }
552}