1use std::collections::{HashMap, HashSet, VecDeque};
7
8pub type ComplianceComponentData = (String, Option<String>, Vec<String>, Vec<(String, String)>);
10
11#[allow(dead_code)]
13#[derive(Debug, Clone, Default)]
14pub struct BlastRadius {
15 pub direct_dependents: Vec<String>,
17 pub transitive_dependents: HashSet<String>,
19 pub max_depth: usize,
21 pub risk_level: RiskLevel,
23 pub critical_paths: Vec<Vec<String>>,
25}
26
27impl BlastRadius {}
28
29#[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#[allow(dead_code)]
42#[derive(Debug, Clone, Default)]
43pub struct RiskIndicators {
44 pub vuln_count: usize,
46 pub highest_severity: Option<String>,
48 pub direct_dependent_count: usize,
50 pub transitive_dependent_count: usize,
52 pub license_risk: LicenseRisk,
54 pub is_direct_dep: bool,
56 pub depth: usize,
58 pub risk_score: u8,
60 pub risk_level: RiskLevel,
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
66pub enum LicenseRisk {
67 #[default]
68 None,
69 Low, Medium, High, }
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#[allow(dead_code)]
107#[derive(Debug, Clone)]
108pub struct FlaggedItem {
109 pub component_id: String,
111 pub reason: String,
113 pub note: Option<String>,
115 pub flagged_at: std::time::Instant,
117}
118
119#[allow(dead_code)]
121#[derive(Debug, Default)]
122pub struct SecurityAnalysisCache {
123 pub blast_radius_cache: HashMap<String, BlastRadius>,
125 pub risk_indicators_cache: HashMap<String, RiskIndicators>,
127 pub flagged_items: Vec<FlaggedItem>,
129 pub flagged_set: HashSet<String>,
131}
132
133impl SecurityAnalysisCache {
134 pub(crate) fn new() -> Self {
135 Self::default()
136 }
137
138 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 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 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 pub(crate) fn is_flagged(&self, component_id: &str) -> bool {
169 self.flagged_set.contains(component_id)
170 }
171
172 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 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
191pub 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
209pub fn calculate_fix_urgency(severity_rank: u8, blast_radius: usize, cvss_score: f32) -> u8 {
211 let severity_score = u32::from(severity_rank) * 10;
213
214 let cvss_contribution = (cvss_score * 3.0) as u32;
216
217 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#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum VersionChange {
235 Upgrade,
237 Downgrade,
239 NoChange,
241 Unknown,
243}
244
245pub fn detect_version_downgrade(old_version: &str, new_version: &str) -> VersionChange {
247 if old_version == new_version {
248 return VersionChange::NoChange;
249 }
250
251 if let (Some(old_parts), Some(new_parts)) = (
253 parse_version_parts(old_version),
254 parse_version_parts(new_version),
255 ) {
256 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 new_parts.len() < old_parts.len() {
266 return VersionChange::Downgrade; } else if new_parts.len() > old_parts.len() {
268 return VersionChange::Upgrade; }
270 return VersionChange::NoChange;
271 }
272
273 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
281fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
283 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,
299 Major,
301 Suspicious,
303}
304
305pub 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 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 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
335fn sanitize_vuln_id(id: &str) -> String {
339 id.chars()
340 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
341 .collect()
342}
343
344pub 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 format!("https://osv.dev/vulnerability/{safe_id}")
361 }
362}
363
364fn 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
399pub 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 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
436pub 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 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 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#[derive(Debug, Clone)]
512pub struct AttackPath {
513 pub path: Vec<String>,
515 pub depth: usize,
517 pub risk_score: u8,
519}
520
521impl AttackPath {
522 pub(crate) fn format(&self) -> String {
524 self.path.join(" → ")
525 }
526
527 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
537pub 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 for root in root_components {
549 if root == target {
550 paths.push(AttackPath {
552 path: vec![root.clone()],
553 depth: 0,
554 risk_score: 100, });
556 continue;
557 }
558
559 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 if let Some(deps) = forward_graph.get(¤t) {
572 for dep in deps {
573 if dep == target {
574 let mut full_path = path.clone();
576 full_path.push(dep.clone());
577 let depth = full_path.len() - 1;
578
579 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 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 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
619pub 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#[derive(Debug, Clone)]
637pub enum PolicyRule {
638 BannedLicense { pattern: String, reason: String },
640 BannedComponent { pattern: String, reason: String },
642 NoPreRelease { reason: String },
644 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
674pub enum PolicySeverity {
675 Low,
676 Medium,
677 High,
678 Critical,
679}
680
681#[allow(dead_code)]
683#[derive(Debug, Clone)]
684pub struct PolicyViolation {
685 pub rule_name: String,
687 pub severity: PolicySeverity,
689 pub component: Option<String>,
691 pub description: String,
693 pub remediation: String,
695}
696
697#[derive(Debug, Clone, Default)]
699pub struct SecurityPolicy {
700 pub name: String,
702 pub rules: Vec<PolicyRule>,
704}
705
706impl SecurityPolicy {
707 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 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 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#[allow(dead_code)]
778#[derive(Debug, Clone, Default)]
779pub struct ComplianceResult {
780 pub policy_name: String,
782 pub components_checked: usize,
784 pub violations: Vec<PolicyViolation>,
786 pub score: u8,
788 pub passes: bool,
790}
791
792impl ComplianceResult {
793 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
802pub 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 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 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 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 let url = cve_url("CVE-2021-44228&calc");
947 assert!(!url.contains('&'));
948 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 assert!(!is_safe_url("https://evil.com\"; rm -rf /"));
960 assert!(!is_safe_url("https://x.com\nmalicious"));
961 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}