Skip to main content

sbom_tools/tui/
security.rs

1//! Security analysis utilities for TUI.
2//!
3//! Provides blast radius analysis, risk indicators, and security-focused
4//! utilities for security analysts working with SBOMs.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8/// Component compliance data: (name, version, licenses, vulns\[(id, severity)\]).
9pub type ComplianceComponentData = (String, Option<String>, Vec<String>, Vec<(String, String)>);
10
11/// Blast radius analysis result for a component
12#[allow(dead_code)]
13#[derive(Debug, Clone, Default)]
14pub struct BlastRadius {
15    /// Direct dependents (components that directly depend on this)
16    pub direct_dependents: Vec<String>,
17    /// All transitive dependents (full blast radius)
18    pub transitive_dependents: HashSet<String>,
19    /// Maximum depth of impact
20    pub max_depth: usize,
21    /// Risk level based on impact
22    pub risk_level: RiskLevel,
23    /// Critical paths (paths to important components)
24    pub critical_paths: Vec<Vec<String>>,
25}
26
27impl BlastRadius {}
28
29/// Risk level for a component
30#[allow(dead_code)]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum RiskLevel {
33    #[default]
34    Low,
35    Medium,
36    High,
37    Critical,
38}
39
40/// Risk indicators for a component
41#[allow(dead_code)]
42#[derive(Debug, Clone, Default)]
43pub struct RiskIndicators {
44    /// Vulnerability count
45    pub vuln_count: usize,
46    /// Highest vulnerability severity
47    pub highest_severity: Option<String>,
48    /// Number of direct dependents
49    pub direct_dependent_count: usize,
50    /// Number of transitive dependents (blast radius)
51    pub transitive_dependent_count: usize,
52    /// License risk (unknown, copyleft, etc.)
53    pub license_risk: LicenseRisk,
54    /// Is this a direct dependency (depth 1)
55    pub is_direct_dep: bool,
56    /// Dependency depth from root
57    pub depth: usize,
58    /// Overall risk score (0-100)
59    pub risk_score: u8,
60    /// Overall risk level
61    pub risk_level: RiskLevel,
62}
63
64/// License risk level
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
66pub enum LicenseRisk {
67    #[default]
68    None,
69    Low,    // Permissive (MIT, Apache, BSD)
70    Medium, // Weak copyleft (LGPL, MPL)
71    High,   // Strong copyleft (GPL, AGPL) or Unknown
72}
73
74impl LicenseRisk {
75    pub(crate) fn from_license(license: &str) -> Self {
76        let lower = license.to_lowercase();
77
78        if lower.contains("unlicense")
79            || lower.contains("mit")
80            || lower.contains("apache")
81            || lower.contains("bsd")
82            || lower.contains("isc")
83            || lower.contains("cc0")
84        {
85            Self::Low
86        } else if lower.contains("lgpl") || lower.contains("mpl") || lower.contains("cddl") {
87            Self::Medium
88        } else if lower.contains("gpl") || lower.contains("agpl") || lower.contains("unknown") {
89            Self::High
90        } else {
91            Self::None
92        }
93    }
94
95    pub(crate) const fn as_str(self) -> &'static str {
96        match self {
97            Self::None => "Unknown",
98            Self::Low => "Permissive",
99            Self::Medium => "Weak Copyleft",
100            Self::High => "Copyleft/Unknown",
101        }
102    }
103}
104
105/// Flagged item for analyst follow-up
106#[allow(dead_code)]
107#[derive(Debug, Clone)]
108pub struct FlaggedItem {
109    /// Component ID or name
110    pub component_id: String,
111    /// Reason for flagging
112    pub reason: String,
113    /// Optional analyst note
114    pub note: Option<String>,
115    /// Timestamp
116    pub flagged_at: std::time::Instant,
117}
118
119/// Security analysis cache for the TUI
120#[allow(dead_code)]
121#[derive(Debug, Default)]
122pub struct SecurityAnalysisCache {
123    /// Cached blast radius for components
124    pub blast_radius_cache: HashMap<String, BlastRadius>,
125    /// Cached risk indicators
126    pub risk_indicators_cache: HashMap<String, RiskIndicators>,
127    /// Flagged items for follow-up
128    pub flagged_items: Vec<FlaggedItem>,
129    /// Components flagged (for quick lookup)
130    pub flagged_set: HashSet<String>,
131}
132
133impl SecurityAnalysisCache {
134    pub(crate) fn new() -> Self {
135        Self::default()
136    }
137
138    /// Flag a component for follow-up
139    pub(crate) fn flag_component(&mut self, component_id: &str, reason: &str) {
140        if !self.flagged_set.contains(component_id) {
141            self.flagged_items.push(FlaggedItem {
142                component_id: component_id.to_string(),
143                reason: reason.to_string(),
144                note: None,
145                flagged_at: std::time::Instant::now(),
146            });
147            self.flagged_set.insert(component_id.to_string());
148        }
149    }
150
151    /// Unflag a component
152    pub(crate) fn unflag_component(&mut self, component_id: &str) {
153        self.flagged_items
154            .retain(|item| item.component_id != component_id);
155        self.flagged_set.remove(component_id);
156    }
157
158    /// Toggle flag status
159    pub(crate) fn toggle_flag(&mut self, component_id: &str, reason: &str) {
160        if self.flagged_set.contains(component_id) {
161            self.unflag_component(component_id);
162        } else {
163            self.flag_component(component_id, reason);
164        }
165    }
166
167    /// Check if a component is flagged
168    pub(crate) fn is_flagged(&self, component_id: &str) -> bool {
169        self.flagged_set.contains(component_id)
170    }
171
172    /// Add note to a flagged component
173    pub(crate) fn add_note(&mut self, component_id: &str, note: &str) {
174        for item in &mut self.flagged_items {
175            if item.component_id == component_id {
176                item.note = Some(note.to_string());
177                break;
178            }
179        }
180    }
181
182    /// Get note for a flagged component
183    pub(crate) fn get_note(&self, component_id: &str) -> Option<&str> {
184        self.flagged_items
185            .iter()
186            .find(|item| item.component_id == component_id)
187            .and_then(|item| item.note.as_deref())
188    }
189}
190
191// ============================================================================
192// Vulnerability Prioritization
193// ============================================================================
194
195/// Convert severity string to numeric rank for sorting
196pub fn severity_to_rank(severity: &str) -> u8 {
197    let s = severity.to_lowercase();
198    if s.contains("critical") {
199        4
200    } else if s.contains("high") {
201        3
202    } else if s.contains("medium") || s.contains("moderate") {
203        2
204    } else {
205        u8::from(s.contains("low"))
206    }
207}
208
209/// Calculate fix urgency score (0-100) based on severity and blast radius
210pub fn calculate_fix_urgency(severity_rank: u8, blast_radius: usize, cvss_score: f32) -> u8 {
211    // Base score from severity (0-40)
212    let severity_score = u32::from(severity_rank) * 10;
213
214    // CVSS contribution (0-30)
215    let cvss_contribution = (cvss_score * 3.0) as u32;
216
217    // Blast radius contribution (0-30)
218    let blast_score = match blast_radius {
219        0 => 0,
220        1..=5 => 10,
221        6..=20 => 20,
222        _ => 30,
223    };
224
225    (severity_score + cvss_contribution + blast_score).min(100) as u8
226}
227
228// ============================================================================
229// Version Downgrade Detection
230// ============================================================================
231
232/// Result of version comparison for downgrade detection
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum VersionChange {
235    /// Version increased (normal upgrade)
236    Upgrade,
237    /// Version decreased (potential attack)
238    Downgrade,
239    /// Same version
240    NoChange,
241    /// Cannot determine (unparseable versions)
242    Unknown,
243}
244
245/// Detect if a version change is a downgrade (potential supply chain attack)
246pub fn detect_version_downgrade(old_version: &str, new_version: &str) -> VersionChange {
247    if old_version == new_version {
248        return VersionChange::NoChange;
249    }
250
251    // Try semver parsing first
252    if let (Some(old_parts), Some(new_parts)) = (
253        parse_version_parts(old_version),
254        parse_version_parts(new_version),
255    ) {
256        // Compare major.minor.patch
257        for (old, new) in old_parts.iter().zip(new_parts.iter()) {
258            if new > old {
259                return VersionChange::Upgrade;
260            } else if new < old {
261                return VersionChange::Downgrade;
262            }
263        }
264        // If we get here, versions are equal up to the compared parts
265        if new_parts.len() < old_parts.len() {
266            return VersionChange::Downgrade; // e.g., 1.2.3 -> 1.2
267        } else if new_parts.len() > old_parts.len() {
268            return VersionChange::Upgrade; // e.g., 1.2 -> 1.2.3
269        }
270        return VersionChange::NoChange;
271    }
272
273    // Fallback: lexicographic comparison (less reliable)
274    match new_version.cmp(old_version) {
275        std::cmp::Ordering::Less => VersionChange::Downgrade,
276        std::cmp::Ordering::Greater => VersionChange::Upgrade,
277        std::cmp::Ordering::Equal => VersionChange::Unknown,
278    }
279}
280
281/// Parse version string into numeric parts
282fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
283    // Remove common prefixes like 'v', 'V', 'version-'
284    let cleaned = version
285        .trim_start_matches(|c: char| !c.is_ascii_digit())
286        .split(|c: char| !c.is_ascii_digit() && c != '.')
287        .next()
288        .unwrap_or(version);
289
290    let parts: Vec<u32> = cleaned.split('.').filter_map(|p| p.parse().ok()).collect();
291
292    if parts.is_empty() { None } else { Some(parts) }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum DowngradeSeverity {
297    /// Minor version downgrade (e.g., 1.2.3 -> 1.2.2)
298    Minor,
299    /// Major version downgrade (e.g., 2.0.0 -> 1.9.0)
300    Major,
301    /// Suspicious pattern (e.g., security patch removed)
302    Suspicious,
303}
304
305/// Analyze a version change for downgrade severity
306pub fn analyze_downgrade(old_version: &str, new_version: &str) -> Option<DowngradeSeverity> {
307    if detect_version_downgrade(old_version, new_version) != VersionChange::Downgrade {
308        return None;
309    }
310
311    let old_parts = parse_version_parts(old_version)?;
312    let new_parts = parse_version_parts(new_version)?;
313
314    // Check if major version decreased
315    if let (Some(&old_major), Some(&new_major)) = (old_parts.first(), new_parts.first())
316        && new_major < old_major
317    {
318        return Some(DowngradeSeverity::Major);
319    }
320
321    // Check for suspicious patterns (security-related version strings)
322    let old_lower = old_version.to_lowercase();
323    let new_lower = new_version.to_lowercase();
324    if (old_lower.contains("security") || old_lower.contains("patch") || old_lower.contains("fix"))
325        && !new_lower.contains("security")
326        && !new_lower.contains("patch")
327        && !new_lower.contains("fix")
328    {
329        return Some(DowngradeSeverity::Suspicious);
330    }
331
332    Some(DowngradeSeverity::Minor)
333}
334
335/// Sanitize a vulnerability ID to contain only safe characters.
336/// Allows alphanumeric, hyphen, underscore, dot, and colon — sufficient
337/// for CVE, GHSA, RUSTSEC, PYSEC, and other standard advisory IDs.
338fn sanitize_vuln_id(id: &str) -> String {
339    id.chars()
340        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
341        .collect()
342}
343
344/// Format a CVE ID as a URL for opening in browser
345pub fn cve_url(cve_id: &str) -> String {
346    let safe_id = sanitize_vuln_id(cve_id);
347    if safe_id.to_uppercase().starts_with("CVE-") {
348        format!(
349            "https://nvd.nist.gov/vuln/detail/{}",
350            safe_id.to_uppercase()
351        )
352    } else if safe_id.to_uppercase().starts_with("GHSA-") {
353        format!("https://github.com/advisories/{}", safe_id.to_uppercase())
354    } else if safe_id.starts_with("RUSTSEC-") {
355        format!("https://rustsec.org/advisories/{safe_id}")
356    } else if safe_id.starts_with("PYSEC-") {
357        format!("https://osv.dev/vulnerability/{safe_id}")
358    } else {
359        // Generic OSV lookup
360        format!("https://osv.dev/vulnerability/{safe_id}")
361    }
362}
363
364/// Validate that a URL contains only characters from RFC 3986
365/// (unreserved + reserved + percent-encoded). Rejects control characters,
366/// spaces, backticks, pipes, and other non-URL characters that could be
367/// misinterpreted by platform open commands.
368fn is_safe_url(url: &str) -> bool {
369    url.chars().all(|c| {
370        c.is_ascii_alphanumeric()
371            || matches!(
372                c,
373                ':' | '/'
374                    | '.'
375                    | '-'
376                    | '_'
377                    | '~'
378                    | '?'
379                    | '#'
380                    | '['
381                    | ']'
382                    | '@'
383                    | '!'
384                    | '$'
385                    | '&'
386                    | '\''
387                    | '('
388                    | ')'
389                    | '*'
390                    | '+'
391                    | ','
392                    | ';'
393                    | '='
394                    | '%'
395            )
396    })
397}
398
399/// Open a URL in the default browser
400pub fn open_in_browser(url: &str) -> Result<(), String> {
401    if !is_safe_url(url) {
402        return Err("URL contains unsafe characters".to_string());
403    }
404
405    #[cfg(target_os = "macos")]
406    {
407        std::process::Command::new("open")
408            .arg(url)
409            .spawn()
410            .map_err(|e| format!("Failed to open browser: {e}"))?;
411    }
412
413    #[cfg(target_os = "linux")]
414    {
415        std::process::Command::new("xdg-open")
416            .arg(url)
417            .spawn()
418            .map_err(|e| format!("Failed to open browser: {e}"))?;
419    }
420
421    #[cfg(target_os = "windows")]
422    {
423        // Use explorer.exe instead of cmd /C start to avoid shell
424        // metacharacter interpretation (e.g. & | > would be dangerous
425        // with cmd.exe). explorer.exe receives the URL as a direct
426        // process argument with no shell involved.
427        std::process::Command::new("explorer")
428            .arg(url)
429            .spawn()
430            .map_err(|e| format!("Failed to open browser: {e}"))?;
431    }
432
433    Ok(())
434}
435
436/// Copy text to system clipboard
437pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
438    #[cfg(target_os = "macos")]
439    {
440        use std::io::Write;
441        let mut child = std::process::Command::new("pbcopy")
442            .stdin(std::process::Stdio::piped())
443            .spawn()
444            .map_err(|e| format!("Failed to copy to clipboard: {e}"))?;
445
446        if let Some(stdin) = child.stdin.as_mut() {
447            stdin
448                .write_all(text.as_bytes())
449                .map_err(|e| format!("Failed to write to clipboard: {e}"))?;
450        }
451        child
452            .wait()
453            .map_err(|e| format!("Clipboard command failed: {e}"))?;
454    }
455
456    #[cfg(target_os = "linux")]
457    {
458        use std::io::Write;
459        // Try xclip first, then xsel
460        let result = std::process::Command::new("xclip")
461            .args(["-selection", "clipboard"])
462            .stdin(std::process::Stdio::piped())
463            .spawn();
464
465        let mut child = match result {
466            Ok(child) => child,
467            Err(_) => std::process::Command::new("xsel")
468                .args(["--clipboard", "--input"])
469                .stdin(std::process::Stdio::piped())
470                .spawn()
471                .map_err(|e| format!("Failed to copy to clipboard: {e}"))?,
472        };
473
474        if let Some(stdin) = child.stdin.as_mut() {
475            stdin
476                .write_all(text.as_bytes())
477                .map_err(|e| format!("Failed to write to clipboard: {e}"))?;
478        }
479        child
480            .wait()
481            .map_err(|e| format!("Clipboard command failed: {e}"))?;
482    }
483
484    #[cfg(target_os = "windows")]
485    {
486        // Use clip.exe with stdin to avoid command injection via string interpolation
487        use std::io::Write;
488        let mut child = std::process::Command::new("clip")
489            .stdin(std::process::Stdio::piped())
490            .spawn()
491            .map_err(|e| format!("Failed to copy to clipboard: {e}"))?;
492
493        if let Some(stdin) = child.stdin.as_mut() {
494            stdin
495                .write_all(text.as_bytes())
496                .map_err(|e| format!("Failed to write to clipboard: {e}"))?;
497        }
498        child
499            .wait()
500            .map_err(|e| format!("Clipboard command failed: {e}"))?;
501    }
502
503    Ok(())
504}
505
506// ============================================================================
507// Attack Path Visualization
508// ============================================================================
509
510/// An attack path from an entry point to a vulnerable component
511#[derive(Debug, Clone)]
512pub struct AttackPath {
513    /// The path of component names from entry point to target
514    pub path: Vec<String>,
515    /// Path length (number of hops)
516    pub depth: usize,
517    /// Risk score based on path characteristics
518    pub risk_score: u8,
519}
520
521impl AttackPath {
522    /// Format the path as a readable string
523    pub(crate) fn format(&self) -> String {
524        self.path.join(" → ")
525    }
526
527    /// Get a short description of the path
528    pub(crate) fn description(&self) -> String {
529        if self.depth == 1 {
530            "Direct dependency".to_string()
531        } else {
532            format!("{} hops", self.depth)
533        }
534    }
535}
536
537/// Find attack paths from root components to a vulnerable component
538pub fn find_attack_paths(
539    target: &str,
540    forward_graph: &HashMap<String, Vec<String>>,
541    root_components: &[String],
542    max_paths: usize,
543    max_depth: usize,
544) -> Vec<AttackPath> {
545    let mut paths = Vec::new();
546
547    // BFS from each root to find paths to target
548    for root in root_components {
549        if root == target {
550            // Direct hit - root is the vulnerable component
551            paths.push(AttackPath {
552                path: vec![root.clone()],
553                depth: 0,
554                risk_score: 100, // Highest risk - direct exposure
555            });
556            continue;
557        }
558
559        // BFS to find path from this root to target
560        let mut visited: HashSet<String> = HashSet::new();
561        let mut queue: VecDeque<(String, Vec<String>)> = VecDeque::new();
562        queue.push_back((root.clone(), vec![root.clone()]));
563        visited.insert(root.clone());
564
565        while let Some((current, path)) = queue.pop_front() {
566            if path.len() > max_depth {
567                continue;
568            }
569
570            // Check all dependencies of current node
571            if let Some(deps) = forward_graph.get(&current) {
572                for dep in deps {
573                    if dep == target {
574                        // Found a path!
575                        let mut full_path = path.clone();
576                        full_path.push(dep.clone());
577                        let depth = full_path.len() - 1;
578
579                        // Risk score decreases with depth
580                        let risk_score = match depth {
581                            1 => 90,
582                            2 => 70,
583                            3 => 50,
584                            4 => 30,
585                            _ => 10,
586                        };
587
588                        paths.push(AttackPath {
589                            path: full_path,
590                            depth,
591                            risk_score,
592                        });
593
594                        if paths.len() >= max_paths {
595                            // Sort by risk score before returning
596                            paths.sort_by(|a, b| b.risk_score.cmp(&a.risk_score));
597                            return paths;
598                        }
599                    } else if !visited.contains(dep) {
600                        visited.insert(dep.clone());
601                        let mut new_path = path.clone();
602                        new_path.push(dep.clone());
603                        queue.push_back((dep.clone(), new_path));
604                    }
605                }
606            }
607        }
608    }
609
610    // Sort by risk score (highest first), then by depth (shortest first)
611    paths.sort_by(|a, b| {
612        b.risk_score
613            .cmp(&a.risk_score)
614            .then_with(|| a.depth.cmp(&b.depth))
615    });
616    paths
617}
618
619/// Identify root components (components with no dependents)
620pub fn find_root_components(
621    all_components: &[String],
622    reverse_graph: &HashMap<String, Vec<String>>,
623) -> Vec<String> {
624    all_components
625        .iter()
626        .filter(|comp| reverse_graph.get(*comp).is_none_or(std::vec::Vec::is_empty))
627        .cloned()
628        .collect()
629}
630
631// ============================================================================
632// Compliance / Policy Checking
633// ============================================================================
634
635/// A policy rule for compliance checking
636#[derive(Debug, Clone)]
637pub enum PolicyRule {
638    /// Ban specific licenses (e.g., GPL in proprietary projects)
639    BannedLicense { pattern: String, reason: String },
640    /// Ban specific components by name pattern
641    BannedComponent { pattern: String, reason: String },
642    /// No pre-release versions (0.x.x)
643    NoPreRelease { reason: String },
644    /// Maximum vulnerability severity allowed
645    MaxVulnerabilitySeverity {
646        max_severity: String,
647        reason: String,
648    },
649}
650
651impl PolicyRule {
652    pub(crate) const fn name(&self) -> &'static str {
653        match self {
654            Self::BannedLicense { .. } => "Banned License",
655            Self::BannedComponent { .. } => "Banned Component",
656            Self::NoPreRelease { .. } => "No Pre-Release",
657            Self::MaxVulnerabilitySeverity { .. } => "Max Vulnerability Severity",
658        }
659    }
660
661    pub(crate) const fn severity(&self) -> PolicySeverity {
662        match self {
663            Self::BannedLicense { .. } | Self::MaxVulnerabilitySeverity { .. } => {
664                PolicySeverity::High
665            }
666            Self::BannedComponent { .. } => PolicySeverity::Critical,
667            Self::NoPreRelease { .. } => PolicySeverity::Low,
668        }
669    }
670}
671
672/// Severity of a policy violation
673#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
674pub enum PolicySeverity {
675    Low,
676    Medium,
677    High,
678    Critical,
679}
680
681/// A policy violation
682#[allow(dead_code)]
683#[derive(Debug, Clone)]
684pub struct PolicyViolation {
685    /// The rule that was violated
686    pub rule_name: String,
687    /// Severity of the violation
688    pub severity: PolicySeverity,
689    /// Component that violated the rule (if applicable)
690    pub component: Option<String>,
691    /// Description of what violated the rule
692    pub description: String,
693    /// Suggested remediation
694    pub remediation: String,
695}
696
697/// Security policy configuration
698#[derive(Debug, Clone, Default)]
699pub struct SecurityPolicy {
700    /// Name of this policy
701    pub name: String,
702    /// Policy rules
703    pub rules: Vec<PolicyRule>,
704}
705
706impl SecurityPolicy {
707    /// Create a default enterprise security policy
708    pub(crate) fn enterprise_default() -> Self {
709        Self {
710            name: "Enterprise Security Policy".to_string(),
711            rules: vec![
712                PolicyRule::BannedLicense {
713                    pattern: "GPL".to_string(),
714                    reason: "GPL licenses incompatible with proprietary software".to_string(),
715                },
716                PolicyRule::BannedLicense {
717                    pattern: "AGPL".to_string(),
718                    reason: "AGPL requires source disclosure for network services".to_string(),
719                },
720                PolicyRule::MaxVulnerabilitySeverity {
721                    max_severity: "High".to_string(),
722                    reason: "Critical vulnerabilities must be remediated before deployment"
723                        .to_string(),
724                },
725                PolicyRule::NoPreRelease {
726                    reason: "Pre-release versions (0.x) may have unstable APIs".to_string(),
727                },
728            ],
729        }
730    }
731
732    /// Create a strict security policy
733    pub(crate) fn strict() -> Self {
734        Self {
735            name: "Strict Security Policy".to_string(),
736            rules: vec![
737                PolicyRule::BannedLicense {
738                    pattern: "GPL".to_string(),
739                    reason: "GPL licenses not allowed".to_string(),
740                },
741                PolicyRule::BannedLicense {
742                    pattern: "AGPL".to_string(),
743                    reason: "AGPL licenses not allowed".to_string(),
744                },
745                PolicyRule::BannedLicense {
746                    pattern: "LGPL".to_string(),
747                    reason: "LGPL licenses not allowed".to_string(),
748                },
749                PolicyRule::MaxVulnerabilitySeverity {
750                    max_severity: "Medium".to_string(),
751                    reason: "High/Critical vulnerabilities not allowed".to_string(),
752                },
753                PolicyRule::NoPreRelease {
754                    reason: "Pre-release versions not allowed in production".to_string(),
755                },
756                PolicyRule::BannedComponent {
757                    pattern: "lodash".to_string(),
758                    reason: "Use native JS methods or lighter alternatives".to_string(),
759                },
760            ],
761        }
762    }
763
764    /// Create a permissive policy (minimal checks)
765    pub(crate) fn permissive() -> Self {
766        Self {
767            name: "Permissive Policy".to_string(),
768            rules: vec![PolicyRule::MaxVulnerabilitySeverity {
769                max_severity: "Critical".to_string(),
770                reason: "Critical vulnerabilities should be reviewed".to_string(),
771            }],
772        }
773    }
774}
775
776/// Result of a compliance check
777#[allow(dead_code)]
778#[derive(Debug, Clone, Default)]
779pub struct ComplianceResult {
780    /// Policy name that was checked
781    pub policy_name: String,
782    /// Total components checked
783    pub components_checked: usize,
784    /// Violations found
785    pub violations: Vec<PolicyViolation>,
786    /// Compliance score (0-100)
787    pub score: u8,
788    /// Whether the SBOM passes the policy
789    pub passes: bool,
790}
791
792impl ComplianceResult {
793    /// Count violations by severity
794    pub(crate) fn count_by_severity(&self, severity: PolicySeverity) -> usize {
795        self.violations
796            .iter()
797            .filter(|v| v.severity == severity)
798            .count()
799    }
800}
801
802/// Check compliance of components against a policy
803pub fn check_compliance(
804    policy: &SecurityPolicy,
805    components: &[ComplianceComponentData],
806) -> ComplianceResult {
807    let mut result = ComplianceResult {
808        policy_name: policy.name.clone(),
809        components_checked: components.len(),
810        violations: Vec::new(),
811        score: 100,
812        passes: true,
813    };
814
815    for (name, version, licenses, vulns) in components {
816        for rule in &policy.rules {
817            match rule {
818                PolicyRule::BannedLicense { pattern, reason } => {
819                    for license in licenses {
820                        if license.to_uppercase().contains(&pattern.to_uppercase()) {
821                            result.violations.push(PolicyViolation {
822                                rule_name: rule.name().to_string(),
823                                severity: rule.severity(),
824                                component: Some(name.clone()),
825                                description: format!(
826                                    "License '{license}' matches banned pattern '{pattern}'"
827                                ),
828                                remediation: format!(
829                                    "Replace with component using permissive license. {reason}"
830                                ),
831                            });
832                        }
833                    }
834                }
835                PolicyRule::BannedComponent { pattern, reason } => {
836                    if name.to_lowercase().contains(&pattern.to_lowercase()) {
837                        result.violations.push(PolicyViolation {
838                            rule_name: rule.name().to_string(),
839                            severity: rule.severity(),
840                            component: Some(name.clone()),
841                            description: format!(
842                                "Component '{name}' matches banned pattern '{pattern}'"
843                            ),
844                            remediation: reason.clone(),
845                        });
846                    }
847                }
848                PolicyRule::NoPreRelease { reason } => {
849                    if let Some(ver) = version
850                        && let Some(parts) = parse_version_parts(ver)
851                        && parts.first() == Some(&0)
852                    {
853                        result.violations.push(PolicyViolation {
854                            rule_name: rule.name().to_string(),
855                            severity: rule.severity(),
856                            component: Some(name.clone()),
857                            description: format!("Pre-release version '{ver}' (0.x.x)"),
858                            remediation: format!("Upgrade to stable version (1.0+). {reason}"),
859                        });
860                    }
861                }
862                PolicyRule::MaxVulnerabilitySeverity {
863                    max_severity,
864                    reason,
865                } => {
866                    let max_rank = severity_to_rank(max_severity);
867                    for (vuln_id, vuln_sev) in vulns {
868                        let vuln_rank = severity_to_rank(vuln_sev);
869                        if vuln_rank > max_rank {
870                            result.violations.push(PolicyViolation {
871                                rule_name: rule.name().to_string(),
872                                severity: PolicySeverity::Critical,
873                                component: Some(name.clone()),
874                                description: format!(
875                                    "{vuln_id} has {vuln_sev} severity (max allowed: {max_severity})"
876                                ),
877                                remediation: format!(
878                                    "Remediate {vuln_id} or upgrade component. {reason}"
879                                ),
880                            });
881                        }
882                    }
883                }
884            }
885        }
886    }
887
888    // Calculate score
889    let violation_penalty: u32 = result
890        .violations
891        .iter()
892        .map(|v| match v.severity {
893            PolicySeverity::Critical => 25,
894            PolicySeverity::High => 15,
895            PolicySeverity::Medium => 8,
896            PolicySeverity::Low => 3,
897        })
898        .sum();
899
900    result.score = 100u8.saturating_sub(violation_penalty.min(100) as u8);
901    result.passes = result.count_by_severity(PolicySeverity::Critical) == 0
902        && result.count_by_severity(PolicySeverity::High) == 0;
903
904    result
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910
911    #[test]
912    fn test_license_risk() {
913        assert_eq!(LicenseRisk::from_license("MIT"), LicenseRisk::Low);
914        assert_eq!(LicenseRisk::from_license("Apache-2.0"), LicenseRisk::Low);
915        assert_eq!(LicenseRisk::from_license("LGPL-3.0"), LicenseRisk::Medium);
916        assert_eq!(LicenseRisk::from_license("GPL-3.0"), LicenseRisk::High);
917    }
918
919    #[test]
920    fn test_cve_url() {
921        assert!(cve_url("CVE-2021-44228").contains("nvd.nist.gov"));
922        assert!(cve_url("GHSA-abcd-1234-efgh").contains("github.com"));
923        assert!(cve_url("RUSTSEC-2021-0001").contains("rustsec.org"));
924    }
925
926    #[test]
927    fn test_sanitize_vuln_id_strips_shell_metacharacters() {
928        // Normal IDs pass through unchanged
929        assert_eq!(sanitize_vuln_id("CVE-2021-44228"), "CVE-2021-44228");
930        assert_eq!(
931            sanitize_vuln_id("GHSA-abcd-1234-efgh"),
932            "GHSA-abcd-1234-efgh"
933        );
934
935        // Shell metacharacters are stripped
936        assert_eq!(sanitize_vuln_id("CVE-2021&whoami"), "CVE-2021whoami");
937        assert_eq!(sanitize_vuln_id("CVE|calc.exe"), "CVEcalc.exe");
938        assert_eq!(sanitize_vuln_id("id;rm -rf /"), "idrm-rf");
939        assert_eq!(sanitize_vuln_id("$(malicious)"), "malicious");
940        assert_eq!(sanitize_vuln_id("foo`bar`"), "foobar");
941    }
942
943    #[test]
944    fn test_cve_url_with_injected_id() {
945        // Ensure shell metacharacters in vuln IDs don't appear in the URL
946        let url = cve_url("CVE-2021-44228&calc");
947        assert!(!url.contains('&'));
948        // sanitize_vuln_id strips '&', cve_url uppercases CVE IDs
949        assert!(url.contains("CVE-2021-44228CALC"));
950    }
951
952    #[test]
953    fn test_is_safe_url() {
954        assert!(is_safe_url(
955            "https://nvd.nist.gov/vuln/detail/CVE-2021-44228"
956        ));
957        assert!(is_safe_url("https://example.com/path?q=1&a=2"));
958        // Shell injection attempts
959        assert!(!is_safe_url("https://evil.com\"; rm -rf /"));
960        assert!(!is_safe_url("https://x.com\nmalicious"));
961        // Backtick and pipe are not valid URL characters
962        assert!(!is_safe_url("url`calc`"));
963        assert!(!is_safe_url("url|cmd"));
964    }
965
966    #[test]
967    fn test_security_cache_flagging() {
968        let mut cache = SecurityAnalysisCache::new();
969
970        assert!(!cache.is_flagged("comp1"));
971        cache.flag_component("comp1", "Suspicious activity");
972        assert!(cache.is_flagged("comp1"));
973
974        cache.toggle_flag("comp1", "test");
975        assert!(!cache.is_flagged("comp1"));
976    }
977}