use std::collections::{HashMap, HashSet, VecDeque};
pub type ComplianceComponentData = (String, Option<String>, Vec<String>, Vec<(String, String)>);
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct BlastRadius {
pub direct_dependents: Vec<String>,
pub transitive_dependents: HashSet<String>,
pub max_depth: usize,
pub risk_level: RiskLevel,
pub critical_paths: Vec<Vec<String>>,
}
impl BlastRadius {}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RiskLevel {
#[default]
Low,
Medium,
High,
Critical,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct RiskIndicators {
pub vuln_count: usize,
pub highest_severity: Option<String>,
pub direct_dependent_count: usize,
pub transitive_dependent_count: usize,
pub license_risk: LicenseRisk,
pub is_direct_dep: bool,
pub depth: usize,
pub risk_score: u8,
pub risk_level: RiskLevel,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LicenseRisk {
#[default]
None,
Low, Medium, High, }
impl LicenseRisk {
pub(crate) fn from_license(license: &str) -> Self {
let lower = license.to_lowercase();
if lower.contains("unlicense")
|| lower.contains("mit")
|| lower.contains("apache")
|| lower.contains("bsd")
|| lower.contains("isc")
|| lower.contains("cc0")
{
Self::Low
} else if lower.contains("lgpl") || lower.contains("mpl") || lower.contains("cddl") {
Self::Medium
} else if lower.contains("gpl") || lower.contains("agpl") || lower.contains("unknown") {
Self::High
} else {
Self::None
}
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::None => "Unknown",
Self::Low => "Permissive",
Self::Medium => "Weak Copyleft",
Self::High => "Copyleft/Unknown",
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct FlaggedItem {
pub component_id: String,
pub reason: String,
pub note: Option<String>,
pub flagged_at: std::time::Instant,
}
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct SecurityAnalysisCache {
pub blast_radius_cache: HashMap<String, BlastRadius>,
pub risk_indicators_cache: HashMap<String, RiskIndicators>,
pub flagged_items: Vec<FlaggedItem>,
pub flagged_set: HashSet<String>,
}
impl SecurityAnalysisCache {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn flag_component(&mut self, component_id: &str, reason: &str) {
if !self.flagged_set.contains(component_id) {
self.flagged_items.push(FlaggedItem {
component_id: component_id.to_string(),
reason: reason.to_string(),
note: None,
flagged_at: std::time::Instant::now(),
});
self.flagged_set.insert(component_id.to_string());
}
}
pub(crate) fn unflag_component(&mut self, component_id: &str) {
self.flagged_items
.retain(|item| item.component_id != component_id);
self.flagged_set.remove(component_id);
}
pub(crate) fn toggle_flag(&mut self, component_id: &str, reason: &str) {
if self.flagged_set.contains(component_id) {
self.unflag_component(component_id);
} else {
self.flag_component(component_id, reason);
}
}
pub(crate) fn is_flagged(&self, component_id: &str) -> bool {
self.flagged_set.contains(component_id)
}
pub(crate) fn add_note(&mut self, component_id: &str, note: &str) {
for item in &mut self.flagged_items {
if item.component_id == component_id {
item.note = Some(note.to_string());
break;
}
}
}
pub(crate) fn get_note(&self, component_id: &str) -> Option<&str> {
self.flagged_items
.iter()
.find(|item| item.component_id == component_id)
.and_then(|item| item.note.as_deref())
}
}
pub fn severity_to_rank(severity: &str) -> u8 {
let s = severity.to_lowercase();
if s.contains("critical") {
4
} else if s.contains("high") {
3
} else if s.contains("medium") || s.contains("moderate") {
2
} else {
u8::from(s.contains("low"))
}
}
pub fn calculate_fix_urgency(severity_rank: u8, blast_radius: usize, cvss_score: f32) -> u8 {
let severity_score = u32::from(severity_rank) * 10;
let cvss_contribution = if cvss_score.is_finite() {
(cvss_score * 3.0).clamp(0.0, 30.0) as u32
} else {
0
};
let blast_score = match blast_radius {
0 => 0,
1..=5 => 10,
6..=20 => 20,
_ => 30,
};
(severity_score + cvss_contribution + blast_score).min(100) as u8
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionChange {
Upgrade,
Downgrade,
NoChange,
Unknown,
}
pub fn detect_version_downgrade(old_version: &str, new_version: &str) -> VersionChange {
if old_version == new_version {
return VersionChange::NoChange;
}
if let (Some(old_parts), Some(new_parts)) = (
parse_version_parts(old_version),
parse_version_parts(new_version),
) {
for (old, new) in old_parts.iter().zip(new_parts.iter()) {
if new > old {
return VersionChange::Upgrade;
} else if new < old {
return VersionChange::Downgrade;
}
}
if new_parts.len() < old_parts.len() {
return VersionChange::Downgrade; } else if new_parts.len() > old_parts.len() {
return VersionChange::Upgrade; }
return VersionChange::NoChange;
}
match new_version.cmp(old_version) {
std::cmp::Ordering::Less => VersionChange::Downgrade,
std::cmp::Ordering::Greater => VersionChange::Upgrade,
std::cmp::Ordering::Equal => VersionChange::Unknown,
}
}
fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
let cleaned = version
.trim_start_matches(|c: char| !c.is_ascii_digit())
.split(|c: char| !c.is_ascii_digit() && c != '.')
.next()
.unwrap_or(version);
let parts: Vec<u32> = cleaned.split('.').filter_map(|p| p.parse().ok()).collect();
if parts.is_empty() { None } else { Some(parts) }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DowngradeSeverity {
Minor,
Major,
Suspicious,
}
pub fn analyze_downgrade(old_version: &str, new_version: &str) -> Option<DowngradeSeverity> {
if detect_version_downgrade(old_version, new_version) != VersionChange::Downgrade {
return None;
}
let old_parts = parse_version_parts(old_version)?;
let new_parts = parse_version_parts(new_version)?;
if let (Some(&old_major), Some(&new_major)) = (old_parts.first(), new_parts.first())
&& new_major < old_major
{
return Some(DowngradeSeverity::Major);
}
let old_lower = old_version.to_lowercase();
let new_lower = new_version.to_lowercase();
if (old_lower.contains("security") || old_lower.contains("patch") || old_lower.contains("fix"))
&& !new_lower.contains("security")
&& !new_lower.contains("patch")
&& !new_lower.contains("fix")
{
return Some(DowngradeSeverity::Suspicious);
}
Some(DowngradeSeverity::Minor)
}
fn sanitize_vuln_id(id: &str) -> String {
id.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
.collect()
}
pub fn cve_url(cve_id: &str) -> String {
let safe_id = sanitize_vuln_id(cve_id);
if safe_id.to_uppercase().starts_with("CVE-") {
format!(
"https://nvd.nist.gov/vuln/detail/{}",
safe_id.to_uppercase()
)
} else if safe_id.to_uppercase().starts_with("GHSA-") {
format!("https://github.com/advisories/{}", safe_id.to_uppercase())
} else if safe_id.starts_with("RUSTSEC-") {
format!("https://rustsec.org/advisories/{safe_id}")
} else if safe_id.starts_with("PYSEC-") {
format!("https://osv.dev/vulnerability/{safe_id}")
} else {
format!("https://osv.dev/vulnerability/{safe_id}")
}
}
fn is_safe_url(url: &str) -> bool {
url.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
':' | '/'
| '.'
| '-'
| '_'
| '~'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
}
pub fn open_in_browser(url: &str) -> Result<(), String> {
if !is_safe_url(url) {
return Err("URL contains unsafe characters".to_string());
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map_err(|e| format!("Failed to open browser: {e}"))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map_err(|e| format!("Failed to open browser: {e}"))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(url)
.spawn()
.map_err(|e| format!("Failed to open browser: {e}"))?;
}
Ok(())
}
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
use std::io::Write;
let mut child = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to copy to clipboard: {e}"))?;
if let Some(stdin) = child.stdin.as_mut() {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Failed to write to clipboard: {e}"))?;
}
child
.wait()
.map_err(|e| format!("Clipboard command failed: {e}"))?;
}
#[cfg(target_os = "linux")]
{
use std::io::Write;
let result = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn();
let mut child = match result {
Ok(child) => child,
Err(_) => std::process::Command::new("xsel")
.args(["--clipboard", "--input"])
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to copy to clipboard: {e}"))?,
};
if let Some(stdin) = child.stdin.as_mut() {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Failed to write to clipboard: {e}"))?;
}
child
.wait()
.map_err(|e| format!("Clipboard command failed: {e}"))?;
}
#[cfg(target_os = "windows")]
{
use std::io::Write;
let mut child = std::process::Command::new("clip")
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to copy to clipboard: {e}"))?;
if let Some(stdin) = child.stdin.as_mut() {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Failed to write to clipboard: {e}"))?;
}
child
.wait()
.map_err(|e| format!("Clipboard command failed: {e}"))?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct AttackPath {
pub path: Vec<String>,
pub depth: usize,
pub risk_score: u8,
}
impl AttackPath {
pub(crate) fn format(&self) -> String {
self.path.join(" → ")
}
pub(crate) fn description(&self) -> String {
if self.depth == 1 {
"Direct dependency".to_string()
} else {
format!("{} hops", self.depth)
}
}
}
pub fn find_attack_paths(
target: &str,
forward_graph: &HashMap<String, Vec<String>>,
root_components: &[String],
max_paths: usize,
max_depth: usize,
) -> Vec<AttackPath> {
let mut paths = Vec::new();
for root in root_components {
if root == target {
paths.push(AttackPath {
path: vec![root.clone()],
depth: 0,
risk_score: 100, });
continue;
}
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(String, Vec<String>)> = VecDeque::new();
queue.push_back((root.clone(), vec![root.clone()]));
visited.insert(root.clone());
while let Some((current, path)) = queue.pop_front() {
if path.len() > max_depth {
continue;
}
if let Some(deps) = forward_graph.get(¤t) {
for dep in deps {
if dep == target {
let mut full_path = path.clone();
full_path.push(dep.clone());
let depth = full_path.len() - 1;
let risk_score = match depth {
1 => 90,
2 => 70,
3 => 50,
4 => 30,
_ => 10,
};
paths.push(AttackPath {
path: full_path,
depth,
risk_score,
});
if paths.len() >= max_paths {
paths.sort_by(|a, b| b.risk_score.cmp(&a.risk_score));
return paths;
}
} else if !visited.contains(dep) {
visited.insert(dep.clone());
let mut new_path = path.clone();
new_path.push(dep.clone());
queue.push_back((dep.clone(), new_path));
}
}
}
}
}
paths.sort_by(|a, b| {
b.risk_score
.cmp(&a.risk_score)
.then_with(|| a.depth.cmp(&b.depth))
});
paths
}
pub fn find_root_components(
all_components: &[String],
reverse_graph: &HashMap<String, Vec<String>>,
) -> Vec<String> {
all_components
.iter()
.filter(|comp| reverse_graph.get(*comp).is_none_or(std::vec::Vec::is_empty))
.cloned()
.collect()
}
#[derive(Debug, Clone)]
pub enum PolicyRule {
BannedLicense { pattern: String, reason: String },
BannedComponent { pattern: String, reason: String },
NoPreRelease { reason: String },
MaxVulnerabilitySeverity {
max_severity: String,
reason: String,
},
}
impl PolicyRule {
pub(crate) const fn name(&self) -> &'static str {
match self {
Self::BannedLicense { .. } => "Banned License",
Self::BannedComponent { .. } => "Banned Component",
Self::NoPreRelease { .. } => "No Pre-Release",
Self::MaxVulnerabilitySeverity { .. } => "Max Vulnerability Severity",
}
}
pub(crate) const fn severity(&self) -> PolicySeverity {
match self {
Self::BannedLicense { .. } | Self::MaxVulnerabilitySeverity { .. } => {
PolicySeverity::High
}
Self::BannedComponent { .. } => PolicySeverity::Critical,
Self::NoPreRelease { .. } => PolicySeverity::Low,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PolicySeverity {
Low,
Medium,
High,
Critical,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PolicyViolation {
pub rule_name: String,
pub severity: PolicySeverity,
pub component: Option<String>,
pub description: String,
pub remediation: String,
}
#[derive(Debug, Clone, Default)]
pub struct SecurityPolicy {
pub name: String,
pub rules: Vec<PolicyRule>,
}
impl SecurityPolicy {
pub(crate) fn enterprise_default() -> Self {
Self {
name: "Enterprise Security Policy".to_string(),
rules: vec![
PolicyRule::BannedLicense {
pattern: "GPL".to_string(),
reason: "GPL licenses incompatible with proprietary software".to_string(),
},
PolicyRule::BannedLicense {
pattern: "AGPL".to_string(),
reason: "AGPL requires source disclosure for network services".to_string(),
},
PolicyRule::MaxVulnerabilitySeverity {
max_severity: "High".to_string(),
reason: "Critical vulnerabilities must be remediated before deployment"
.to_string(),
},
PolicyRule::NoPreRelease {
reason: "Pre-release versions (0.x) may have unstable APIs".to_string(),
},
],
}
}
pub(crate) fn strict() -> Self {
Self {
name: "Strict Security Policy".to_string(),
rules: vec![
PolicyRule::BannedLicense {
pattern: "GPL".to_string(),
reason: "GPL licenses not allowed".to_string(),
},
PolicyRule::BannedLicense {
pattern: "AGPL".to_string(),
reason: "AGPL licenses not allowed".to_string(),
},
PolicyRule::BannedLicense {
pattern: "LGPL".to_string(),
reason: "LGPL licenses not allowed".to_string(),
},
PolicyRule::MaxVulnerabilitySeverity {
max_severity: "Medium".to_string(),
reason: "High/Critical vulnerabilities not allowed".to_string(),
},
PolicyRule::NoPreRelease {
reason: "Pre-release versions not allowed in production".to_string(),
},
PolicyRule::BannedComponent {
pattern: "lodash".to_string(),
reason: "Use native JS methods or lighter alternatives".to_string(),
},
],
}
}
pub(crate) fn permissive() -> Self {
Self {
name: "Permissive Policy".to_string(),
rules: vec![PolicyRule::MaxVulnerabilitySeverity {
max_severity: "Critical".to_string(),
reason: "Critical vulnerabilities should be reviewed".to_string(),
}],
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct ComplianceResult {
pub policy_name: String,
pub components_checked: usize,
pub violations: Vec<PolicyViolation>,
pub score: u8,
pub passes: bool,
}
impl ComplianceResult {
pub(crate) fn count_by_severity(&self, severity: PolicySeverity) -> usize {
self.violations
.iter()
.filter(|v| v.severity == severity)
.count()
}
}
pub fn check_compliance(
policy: &SecurityPolicy,
components: &[ComplianceComponentData],
) -> ComplianceResult {
let mut result = ComplianceResult {
policy_name: policy.name.clone(),
components_checked: components.len(),
violations: Vec::new(),
score: 100,
passes: true,
};
for (name, version, licenses, vulns) in components {
for rule in &policy.rules {
match rule {
PolicyRule::BannedLicense { pattern, reason } => {
for license in licenses {
if license.to_uppercase().contains(&pattern.to_uppercase()) {
result.violations.push(PolicyViolation {
rule_name: rule.name().to_string(),
severity: rule.severity(),
component: Some(name.clone()),
description: format!(
"License '{license}' matches banned pattern '{pattern}'"
),
remediation: format!(
"Replace with component using permissive license. {reason}"
),
});
}
}
}
PolicyRule::BannedComponent { pattern, reason } => {
if name.to_lowercase().contains(&pattern.to_lowercase()) {
result.violations.push(PolicyViolation {
rule_name: rule.name().to_string(),
severity: rule.severity(),
component: Some(name.clone()),
description: format!(
"Component '{name}' matches banned pattern '{pattern}'"
),
remediation: reason.clone(),
});
}
}
PolicyRule::NoPreRelease { reason } => {
if let Some(ver) = version
&& let Some(parts) = parse_version_parts(ver)
&& parts.first() == Some(&0)
{
result.violations.push(PolicyViolation {
rule_name: rule.name().to_string(),
severity: rule.severity(),
component: Some(name.clone()),
description: format!("Pre-release version '{ver}' (0.x.x)"),
remediation: format!("Upgrade to stable version (1.0+). {reason}"),
});
}
}
PolicyRule::MaxVulnerabilitySeverity {
max_severity,
reason,
} => {
let max_rank = severity_to_rank(max_severity);
for (vuln_id, vuln_sev) in vulns {
let vuln_rank = severity_to_rank(vuln_sev);
if vuln_rank > max_rank {
result.violations.push(PolicyViolation {
rule_name: rule.name().to_string(),
severity: PolicySeverity::Critical,
component: Some(name.clone()),
description: format!(
"{vuln_id} has {vuln_sev} severity (max allowed: {max_severity})"
),
remediation: format!(
"Remediate {vuln_id} or upgrade component. {reason}"
),
});
}
}
}
}
}
}
let violation_penalty: u32 = result
.violations
.iter()
.map(|v| match v.severity {
PolicySeverity::Critical => 25,
PolicySeverity::High => 15,
PolicySeverity::Medium => 8,
PolicySeverity::Low => 3,
})
.sum();
result.score = 100u8.saturating_sub(violation_penalty.min(100) as u8);
result.passes = result.count_by_severity(PolicySeverity::Critical) == 0
&& result.count_by_severity(PolicySeverity::High) == 0;
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_license_risk() {
assert_eq!(LicenseRisk::from_license("MIT"), LicenseRisk::Low);
assert_eq!(LicenseRisk::from_license("Apache-2.0"), LicenseRisk::Low);
assert_eq!(LicenseRisk::from_license("LGPL-3.0"), LicenseRisk::Medium);
assert_eq!(LicenseRisk::from_license("GPL-3.0"), LicenseRisk::High);
}
#[test]
fn test_cve_url() {
assert!(cve_url("CVE-2021-44228").contains("nvd.nist.gov"));
assert!(cve_url("GHSA-abcd-1234-efgh").contains("github.com"));
assert!(cve_url("RUSTSEC-2021-0001").contains("rustsec.org"));
}
#[test]
fn test_sanitize_vuln_id_strips_shell_metacharacters() {
assert_eq!(sanitize_vuln_id("CVE-2021-44228"), "CVE-2021-44228");
assert_eq!(
sanitize_vuln_id("GHSA-abcd-1234-efgh"),
"GHSA-abcd-1234-efgh"
);
assert_eq!(sanitize_vuln_id("CVE-2021&whoami"), "CVE-2021whoami");
assert_eq!(sanitize_vuln_id("CVE|calc.exe"), "CVEcalc.exe");
assert_eq!(sanitize_vuln_id("id;rm -rf /"), "idrm-rf");
assert_eq!(sanitize_vuln_id("$(malicious)"), "malicious");
assert_eq!(sanitize_vuln_id("foo`bar`"), "foobar");
}
#[test]
fn test_cve_url_with_injected_id() {
let url = cve_url("CVE-2021-44228&calc");
assert!(!url.contains('&'));
assert!(url.contains("CVE-2021-44228CALC"));
}
#[test]
fn test_is_safe_url() {
assert!(is_safe_url(
"https://nvd.nist.gov/vuln/detail/CVE-2021-44228"
));
assert!(is_safe_url("https://example.com/path?q=1&a=2"));
assert!(!is_safe_url("https://evil.com\"; rm -rf /"));
assert!(!is_safe_url("https://x.com\nmalicious"));
assert!(!is_safe_url("url`calc`"));
assert!(!is_safe_url("url|cmd"));
}
#[test]
fn test_security_cache_flagging() {
let mut cache = SecurityAnalysisCache::new();
assert!(!cache.is_flagged("comp1"));
cache.flag_component("comp1", "Suspicious activity");
assert!(cache.is_flagged("comp1"));
cache.toggle_flag("comp1", "test");
assert!(!cache.is_flagged("comp1"));
}
}