Skip to main content

ferrous_forge/rust_version/
security.rs

1//! Security advisory checker for Rust versions
2//!
3//! Checks current Rust version against known security advisories
4//! and warns about vulnerabilities.
5//!
6//! @task T024
7//! @epic T014
8
9use crate::rust_version::{
10    VersionManager, detector::detect_rust_version, file_cache::FileCache, github::GitHubClient,
11    parser::parse_release_notes,
12};
13use crate::{Error, Result};
14use console::style;
15use semver::Version;
16use std::collections::HashMap;
17
18/// Security check result
19#[derive(Debug, Clone)]
20pub struct SecurityCheckResult {
21    /// Whether the current version is secure
22    pub is_secure: bool,
23    /// Current installed version
24    pub current_version: Version,
25    /// List of security issues affecting current version
26    pub issues: Vec<SecurityIssue>,
27    /// Recommended version to update to
28    pub recommended_version: Option<Version>,
29    /// Whether running in offline mode
30    pub offline_mode: bool,
31}
32
33/// Security issue details
34#[derive(Debug, Clone)]
35pub struct SecurityIssue {
36    /// Issue severity
37    pub severity: crate::rust_version::parser::Severity,
38    /// Description of the vulnerability
39    pub description: String,
40    /// CVE ID if available
41    pub cve_id: Option<String>,
42    /// First version that fixes this issue
43    pub fixed_in: Option<Version>,
44    /// Advisory URL
45    pub url: Option<String>,
46}
47
48impl SecurityCheckResult {
49    /// Check if there are critical security issues
50    pub fn has_critical_issues(&self) -> bool {
51        self.issues
52            .iter()
53            .any(|i| i.severity == crate::rust_version::parser::Severity::Critical)
54    }
55
56    /// Get the highest severity level found
57    pub fn highest_severity(&self) -> Option<&crate::rust_version::parser::Severity> {
58        self.issues.iter().map(|i| &i.severity).max()
59    }
60
61    /// Get count of issues by severity
62    pub fn severity_counts(&self) -> HashMap<String, usize> {
63        let mut counts = HashMap::new();
64        for issue in &self.issues {
65            *counts.entry(issue.severity.to_string()).or_insert(0) += 1;
66        }
67        counts
68    }
69}
70
71/// Security advisory checker
72pub struct SecurityChecker {
73    version_manager: VersionManager,
74    cache: FileCache,
75}
76
77impl SecurityChecker {
78    /// Create a new security checker
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the version manager or cache cannot be initialized.
83    pub fn new() -> Result<Self> {
84        let version_manager = VersionManager::new()?;
85        let cache = FileCache::default()?;
86
87        Ok(Self {
88            version_manager,
89            cache,
90        })
91    }
92
93    /// Check current Rust version for security issues
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the current version cannot be detected or
98    /// if the security check fails.
99    pub async fn check_current_version(&self) -> Result<SecurityCheckResult> {
100        let current = detect_rust_version()?;
101        let offline_mode = self.cache.should_use_offline();
102
103        let issues = if offline_mode {
104            self.check_offline(&current.version).await?
105        } else {
106            self.check_online(&current.version).await?
107        };
108
109        // Determine recommended version
110        let recommended_version = if !issues.is_empty() {
111            self.find_recommended_version(&current.version).await?
112        } else {
113            None
114        };
115
116        let is_secure = issues.is_empty();
117
118        Ok(SecurityCheckResult {
119            is_secure,
120            current_version: current.version.clone(),
121            issues,
122            recommended_version,
123            offline_mode,
124        })
125    }
126
127    /// Check for security issues using cached data only
128    async fn check_offline(&self, current_version: &Version) -> Result<Vec<SecurityIssue>> {
129        tracing::info!("Running security check in offline mode");
130
131        // Try to get cached releases
132        let cache_key = "recent_releases_30";
133        let cached = self
134            .cache
135            .get(cache_key)
136            .ok_or_else(|| Error::network("No cached data available for offline security check"))?;
137
138        let releases: Vec<crate::rust_version::GitHubRelease> =
139            serde_json::from_slice(&cached.data)
140                .map_err(|e| Error::parse(format!("Failed to parse cached releases: {e}")))?;
141
142        self.analyze_security_issues(current_version, &releases)
143    }
144
145    /// Check for security issues with online data
146    async fn check_online(&self, current_version: &Version) -> Result<Vec<SecurityIssue>> {
147        let client = GitHubClient::new(None)?;
148
149        // Fetch recent releases
150        let releases = client.get_releases(30).await?;
151
152        // Cache the releases for offline use
153        if let Ok(data) = serde_json::to_vec(&releases) {
154            let _ = self
155                .cache
156                .set("recent_releases_30", data, "application/json");
157        }
158
159        self.analyze_security_issues(current_version, &releases)
160    }
161
162    /// Analyze releases for security issues affecting current version
163    fn analyze_security_issues(
164        &self,
165        current_version: &Version,
166        releases: &[crate::rust_version::GitHubRelease],
167    ) -> Result<Vec<SecurityIssue>> {
168        let mut issues = Vec::new();
169
170        for release in releases {
171            // Only check releases newer than current version
172            if release.version <= *current_version {
173                continue;
174            }
175
176            let parsed = parse_release_notes(&release.tag_name, &release.body);
177
178            for advisory in parsed.security_advisories {
179                issues.push(SecurityIssue {
180                    severity: advisory.severity,
181                    description: advisory.description,
182                    cve_id: advisory.id,
183                    fixed_in: Some(release.version.clone()),
184                    url: Some(release.html_url.clone()),
185                });
186            }
187        }
188
189        // Sort by severity (highest first)
190        issues.sort_by(|a, b| b.severity.cmp(&a.severity));
191
192        Ok(issues)
193    }
194
195    /// Find the recommended version to update to
196    async fn find_recommended_version(
197        &self,
198        _current_version: &Version,
199    ) -> Result<Option<Version>> {
200        // Get the latest release that fixes all known issues
201        let latest = self.version_manager.get_latest_stable().await?;
202        Ok(Some(latest.version))
203    }
204
205    /// Display security check results
206    pub fn display_results(result: &SecurityCheckResult) {
207        println!();
208
209        if result.is_secure {
210            println!("{}", style("✅ Security Check Passed").green().bold());
211            println!(
212                "   Your Rust version {} has no known security vulnerabilities.",
213                style(&result.current_version).green()
214            );
215        } else {
216            let severity = result.highest_severity();
217            let header = match severity {
218                Some(s) if *s == crate::rust_version::parser::Severity::Critical => {
219                    style("🚨 CRITICAL SECURITY ISSUES FOUND").red().bold()
220                }
221                Some(s) if *s == crate::rust_version::parser::Severity::High => {
222                    style("⚠️  HIGH SEVERITY SECURITY ISSUES FOUND")
223                        .yellow()
224                        .bold()
225                }
226                _ => style("⚠️  Security Issues Found").yellow().bold(),
227            };
228
229            println!("{}", header);
230            println!();
231            println!(
232                "   Your Rust version {} has {} known security issue{}.",
233                style(&result.current_version).red(),
234                result.issues.len(),
235                if result.issues.len() == 1 { "" } else { "s" }
236            );
237
238            // Show severity breakdown
239            let counts = result.severity_counts();
240            let mut parts = Vec::new();
241            for (sev, count) in &counts {
242                let styled = match sev.as_str() {
243                    "CRITICAL" => style(format!("{} CRITICAL", count)).red().bold(),
244                    "HIGH" => style(format!("{} HIGH", count)).yellow().bold(),
245                    "MEDIUM" => style(format!("{} MEDIUM", count)).yellow(),
246                    "LOW" => style(format!("{} LOW", count)).dim(),
247                    _ => style(format!("{} {}", count, sev)),
248                };
249                parts.push(styled.to_string());
250            }
251
252            if !parts.is_empty() {
253                println!("   Severity breakdown: {}", parts.join(", "));
254            }
255
256            println!();
257            println!("{}", style("   Security Issues:").bold());
258
259            for (_i, issue) in result.issues.iter().take(5).enumerate() {
260                let sev_icon = match issue.severity {
261                    crate::rust_version::parser::Severity::Critical => "🔴",
262                    crate::rust_version::parser::Severity::High => "🟠",
263                    crate::rust_version::parser::Severity::Medium => "🟡",
264                    crate::rust_version::parser::Severity::Low => "🔵",
265                    crate::rust_version::parser::Severity::Unknown => "⚪",
266                };
267
268                println!();
269                println!(
270                    "   {} {} {}",
271                    sev_icon,
272                    style(format!("[{}]", issue.severity)).bold(),
273                    issue.description
274                );
275
276                if let Some(ref cve) = issue.cve_id {
277                    println!("      CVE: {}", style(cve).cyan());
278                }
279
280                if let Some(ref fixed) = issue.fixed_in {
281                    println!("      Fixed in: {}", style(fixed).green());
282                }
283
284                if let Some(ref url) = issue.url {
285                    println!("      URL: {}", style(url).dim());
286                }
287            }
288
289            if result.issues.len() > 5 {
290                println!("\n   ... and {} more issues", result.issues.len() - 5);
291            }
292
293            println!();
294            if let Some(ref recommended) = result.recommended_version {
295                println!(
296                    "{} {}",
297                    style("🔧 Recommended Action:").bold(),
298                    "Update to the latest version"
299                );
300                println!(
301                    "   Latest secure version: {}",
302                    style(recommended).green().bold()
303                );
304                println!("   Update command: {}", style("rustup update").cyan());
305            }
306        }
307
308        if result.offline_mode {
309            println!();
310            println!(
311                "{}",
312                style("📴 Running in offline mode (using cached data)").dim()
313            );
314        }
315
316        println!();
317    }
318}
319
320/// Quick security check for use during startup
321///
322/// Returns true if no critical issues found, false otherwise.
323///
324/// # Errors
325///
326/// Returns an error if the security check fails to execute.
327pub async fn quick_security_check() -> Result<bool> {
328    let checker = SecurityChecker::new()?;
329
330    match checker.check_current_version().await {
331        Ok(result) => {
332            if !result.is_secure {
333                SecurityChecker::display_results(&result);
334            }
335            Ok(!result.has_critical_issues())
336        }
337        Err(e) => {
338            tracing::warn!("Security check failed: {}", e);
339            // Don't fail on security check errors
340            Ok(true)
341        }
342    }
343}
344
345#[cfg(test)]
346#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_security_check_result_helpers() {
352        let result = SecurityCheckResult {
353            is_secure: false,
354            current_version: Version::new(1, 70, 0),
355            issues: vec![
356                SecurityIssue {
357                    severity: crate::rust_version::parser::Severity::Critical,
358                    description: "Critical bug".to_string(),
359                    cve_id: Some("CVE-2023-1".to_string()),
360                    fixed_in: Some(Version::new(1, 71, 0)),
361                    url: None,
362                },
363                SecurityIssue {
364                    severity: crate::rust_version::parser::Severity::High,
365                    description: "High bug".to_string(),
366                    cve_id: None,
367                    fixed_in: Some(Version::new(1, 71, 0)),
368                    url: None,
369                },
370            ],
371            recommended_version: Some(Version::new(1, 71, 0)),
372            offline_mode: false,
373        };
374
375        assert!(result.has_critical_issues());
376        assert_eq!(
377            result.highest_severity(),
378            Some(&crate::rust_version::parser::Severity::Critical)
379        );
380
381        let counts = result.severity_counts();
382        assert_eq!(counts.get("CRITICAL"), Some(&1));
383        assert_eq!(counts.get("HIGH"), Some(&1));
384    }
385
386    #[test]
387    fn test_security_check_result_secure() {
388        let result = SecurityCheckResult {
389            is_secure: true,
390            current_version: Version::new(1, 70, 0),
391            issues: vec![],
392            recommended_version: None,
393            offline_mode: false,
394        };
395
396        assert!(!result.has_critical_issues());
397        assert!(result.highest_severity().is_none());
398        assert!(result.severity_counts().is_empty());
399    }
400}