use deps_core::diagnostic::{Diagnostic, Severity};
use deps_core::lsp_helpers::{
DEPRECATED_DIAGNOSTIC_CODE, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
UNSATISFIABLE_DIAGNOSTIC_CODE, redact_name_for_diagnostic, redact_requirement_for_diagnostic,
};
use deps_core::osv::{OsvClient, ScanOutcome, VulnKeys, VulnSeverity, VulnerabilityMap};
use deps_core::policy_config::PolicyConfig;
use deps_core::position::Range;
use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
const GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE: &str = "sha-comment-mismatch";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
pub enum Category {
Outdated,
Yanked,
Vulnerable,
Unsatisfiable,
#[value(name = "mutable-ref")]
MutableRefPin,
License,
Deprecated,
#[value(skip)]
Other,
}
impl Category {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Outdated => "outdated",
Self::Yanked => "yanked",
Self::Vulnerable => "vulnerable",
Self::Unsatisfiable => "unsatisfiable",
Self::MutableRefPin => "mutable-ref",
Self::License => "license",
Self::Deprecated => "deprecated",
Self::Other => "other",
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::Outdated => {
"A newer version is published for the dependency's declared requirement."
}
Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
Self::Vulnerable => "A known security advisory affects the in-use version.",
Self::Unsatisfiable => "No published version satisfies the declared requirement.",
Self::MutableRefPin => {
"Pinned to a mutable ref (tag or branch) instead of a commit SHA."
}
Self::License => "The resolved license violates the configured allow/deny policy.",
Self::Deprecated => {
"The registry reports the package itself as deprecated or abandoned."
}
Self::Other => "A finding that does not map to any of the other categories.",
}
}
}
impl std::fmt::Display for Category {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl serde::Serialize for Category {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for Category {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let token = String::deserialize(deserializer)?;
match token.as_str() {
"outdated" => Ok(Self::Outdated),
"yanked" => Ok(Self::Yanked),
"vulnerable" => Ok(Self::Vulnerable),
"unsatisfiable" => Ok(Self::Unsatisfiable),
"mutable-ref" => Ok(Self::MutableRefPin),
"license" => Ok(Self::License),
"deprecated" => Ok(Self::Deprecated),
"other" => Ok(Self::Other),
other => Err(serde::de::Error::unknown_variant(
other,
&[
"outdated",
"yanked",
"vulnerable",
"unsatisfiable",
"mutable-ref",
"license",
"deprecated",
"other",
],
)),
}
}
}
#[derive(Debug, Clone)]
pub struct CheckFinding {
pub ecosystem: EcosystemId,
pub manifest_path: PathBuf,
pub dependency_name: Option<String>,
pub requirement: Option<String>,
pub category: Category,
pub code: Option<String>,
pub advisory_url: Option<String>,
pub advisory_severity: Option<VulnSeverity>,
pub severity: Severity,
pub range: Range,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct CheckReport {
pub findings: Vec<CheckFinding>,
}
impl CheckReport {
#[must_use]
pub fn summary(&self) -> BTreeMap<Category, usize> {
let mut counts = BTreeMap::new();
for finding in &self.findings {
*counts.entry(finding.category).or_insert(0_usize) += 1;
}
counts
}
}
#[derive(Debug, Clone)]
pub struct FailOnPolicy {
categories: Vec<Category>,
}
impl FailOnPolicy {
#[must_use]
pub const fn new(categories: Vec<Category>) -> Self {
Self { categories }
}
#[must_use]
pub fn default_categories() -> Self {
Self::new(vec![
Category::Vulnerable,
Category::Yanked,
Category::Unsatisfiable,
])
}
#[must_use]
pub fn categories(&self) -> &[Category] {
&self.categories
}
#[must_use]
pub fn matches(&self, findings: &[CheckFinding]) -> bool {
findings
.iter()
.any(|finding| self.categories.contains(&finding.category))
}
}
impl Default for FailOnPolicy {
fn default() -> Self {
Self::default_categories()
}
}
#[derive(Clone)]
pub struct CheckContext {
pub cache: Arc<HttpCache>,
pub osv: Arc<OsvClient>,
pub deps_dev: Arc<deps_core::DepsDevClient>,
pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
pub policy: PolicyConfig,
}
#[derive(Debug, thiserror::Error)]
pub enum CheckError {
#[error("failed to parse {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: deps_core::DepsError,
},
#[error("could not build a file URI for {path}")]
InvalidPath {
path: PathBuf,
},
}
#[derive(Debug, Clone)]
pub struct ManifestCheckResult {
pub findings: Vec<CheckFinding>,
pub registry_unreachable: bool,
pub license_fetch_incomplete: bool,
}
pub async fn check_manifest(
ecosystem: &Arc<dyn Ecosystem>,
manifest_path: &Path,
display_path: &Path,
content: &str,
ctx: &CheckContext,
) -> Result<ManifestCheckResult, CheckError> {
let analysis = crate::analyze::analyze_manifest(
ecosystem,
manifest_path,
content,
ctx,
crate::analyze::AnalysisScope::all(),
)
.await?;
let formatter = ecosystem.formatter();
let severities = ctx.policy.diagnostics.to_severities();
let diagnostics = ecosystem
.generate_diagnostics(
analysis.parse_result.as_ref(),
analysis.version_data(),
&analysis.uri,
ctx.policy.freshness.to_freshness(),
severities,
)
.await;
let dep_index = DependencyIndex::build(analysis.parse_result.as_ref());
let advisory_severities = advisory_severity_index(analysis.vulnerabilities.as_ref());
let vuln_keys = deps_core::osv::vulnerability_keys(
analysis.parse_result.as_ref(),
&analysis.resolved_versions,
Some(&analysis.resolved_version_candidates),
formatter,
analysis.ecosystem_id,
);
let findings = diagnostics
.into_iter()
.map(|diagnostic| {
to_finding(
analysis.ecosystem_id,
display_path,
&dep_index,
formatter,
diagnostic,
&advisory_severities,
&vuln_keys,
&analysis.cached_versions,
)
})
.collect();
Ok(ManifestCheckResult {
findings,
registry_unreachable: analysis.registry_unreachable,
license_fetch_incomplete: analysis.license_fetch_incomplete,
})
}
struct DependencyIndex<'a> {
by_name_range: HashMap<Range, &'a dyn Dependency>,
by_version_range: HashMap<Range, &'a dyn Dependency>,
}
impl<'a> DependencyIndex<'a> {
fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
let mut by_name_range = HashMap::new();
let mut by_version_range = HashMap::new();
for dep in parse_result.dependencies() {
if !dep.name_range_is_synthetic() {
by_name_range.insert(dep.name_range(), dep);
}
if let Some(version_range) = dep.version_range() {
by_version_range.insert(version_range, dep);
}
}
Self {
by_name_range,
by_version_range,
}
}
fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
self.by_name_range
.get(&range)
.or_else(|| self.by_version_range.get(&range))
.copied()
}
}
fn advisory_severity_index(
vulnerabilities: Option<&VulnerabilityMap>,
) -> HashMap<(deps_core::osv::VulnKey, String), VulnSeverity> {
let mut index = HashMap::new();
let Some(vulnerabilities) = vulnerabilities else {
return index;
};
for (dependency_key, outcome) in vulnerabilities {
if let ScanOutcome::Vulnerable(dv) = outcome {
for advisory in dv.advisories.items() {
index.insert(
(dependency_key.clone(), advisory.id.clone()),
advisory.severity,
);
}
}
}
index
}
#[allow(
clippy::too_many_arguments,
reason = "internal (non-pub) call-site-controlled classification/attribution inputs; \
grouping into a struct would only move, not reduce, the single production \
call site's churn"
)]
fn to_finding(
ecosystem: EcosystemId,
display_path: &Path,
dep_index: &DependencyIndex<'_>,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
diagnostic: Diagnostic,
advisory_severities: &HashMap<(deps_core::osv::VulnKey, String), VulnSeverity>,
vuln_keys: &VulnKeys,
cached_versions: &HashMap<deps_core::PackageName, deps_core::lsp_helpers::PackageVersions>,
) -> CheckFinding {
let category = classify(&diagnostic, formatter);
let dep = dep_index.lookup(diagnostic.range);
let code = diagnostic.code().map(str::to_string);
let advisory_url = diagnostic
.code_description
.as_ref()
.map(|code_description| code_description.href.as_str().to_string());
let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
if dep.name_range_is_synthetic() {
return None;
}
let dependency_key = deps_core::osv::vuln_key_for(dep, Some(vuln_keys), formatter);
advisory_severities
.get(&(dependency_key, code.to_string()))
.copied()
});
let mut message = diagnostic.message().to_string();
if category == Category::Outdated
&& let Some(dep) = dep
&& cached_versions
.get(dep.name())
.is_some_and(|v| v.gossip_excluded_version.is_some())
{
message.push_str(
" (a newer version was excluded from this pick by an active GOSSIP cooldown finding)",
);
}
CheckFinding {
ecosystem,
manifest_path: crate::sanitize::sanitize_path_for_display(display_path),
dependency_name: dep.map(|d| redact_name_for_diagnostic(d.name())),
requirement: dep
.and_then(Dependency::version_requirement)
.map(redact_requirement_for_diagnostic),
category,
code,
advisory_url,
advisory_severity,
severity: diagnostic.severity.unwrap_or(Severity::Warning),
range: diagnostic.range,
message,
}
}
fn classify(
diagnostic: &Diagnostic,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> Category {
if let Some(code) = diagnostic.code() {
return match code {
UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
Category::MutableRefPin
}
GITLAB_CI_UNRESOLVED_HOST_CODE | GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE => {
Category::Other
}
_ => Category::Vulnerable,
};
}
if diagnostic.message().starts_with("Newer version available") {
return Category::Outdated;
}
if diagnostic.message().contains(formatter.yanked_message()) {
return Category::Yanked;
}
if diagnostic.message().ends_with("more advisories") {
return Category::Vulnerable;
}
Category::Other
}
pub(crate) fn path_to_uri(path: &Path) -> Option<url::Url> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
};
url::Url::from_file_path(&absolute).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use deps_core::PackageName;
fn finding(category: Category) -> CheckFinding {
CheckFinding {
ecosystem: EcosystemId::Cargo,
manifest_path: PathBuf::from("Cargo.toml"),
dependency_name: Some("serde".to_string()),
requirement: Some("1.0".to_string()),
category,
code: None,
advisory_url: None,
advisory_severity: None,
severity: Severity::Warning,
range: Range::default(),
message: "test".to_string(),
}
}
#[test]
fn test_category_as_str_matches_fr009_tokens() {
assert_eq!(Category::Outdated.as_str(), "outdated");
assert_eq!(Category::Yanked.as_str(), "yanked");
assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
assert_eq!(Category::License.as_str(), "license");
assert_eq!(Category::Deprecated.as_str(), "deprecated");
assert_eq!(Category::Other.as_str(), "other");
}
#[test]
fn test_category_serialize_matches_as_str_tokens() {
for category in [
Category::Outdated,
Category::Yanked,
Category::Vulnerable,
Category::Unsatisfiable,
Category::MutableRefPin,
Category::License,
Category::Deprecated,
Category::Other,
] {
let json = serde_json::to_string(&category).expect("Category must serialize");
assert_eq!(json, format!("\"{}\"", category.as_str()));
let parsed: Category = serde_json::from_str(&json).expect("must round-trip");
assert_eq!(parsed, category);
}
}
#[test]
fn test_category_deserialize_rejects_unknown_token() {
let result: Result<Category, _> = serde_json::from_str("\"not-a-real-category\"");
assert!(result.is_err());
}
#[test]
fn test_fail_on_policy_default_categories() {
let policy = FailOnPolicy::default_categories();
assert!(policy.matches(&[finding(Category::Vulnerable)]));
assert!(policy.matches(&[finding(Category::Yanked)]));
assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
assert!(!policy.matches(&[finding(Category::Outdated)]));
assert!(!policy.matches(&[finding(Category::License)]));
assert!(!policy.matches(&[finding(Category::Deprecated)]));
assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
assert!(!policy.matches(&[finding(Category::Other)]));
}
#[test]
fn test_fail_on_policy_custom_categories() {
let policy = FailOnPolicy::new(vec![Category::License]);
assert!(policy.matches(&[finding(Category::License)]));
assert!(!policy.matches(&[finding(Category::Vulnerable)]));
}
#[test]
fn test_fail_on_policy_empty_findings_never_matches() {
assert!(!FailOnPolicy::default_categories().matches(&[]));
}
#[test]
fn test_check_report_summary_counts_per_category() {
let report = CheckReport {
findings: vec![
finding(Category::Outdated),
finding(Category::Outdated),
finding(Category::Vulnerable),
],
};
let summary = report.summary();
assert_eq!(summary.get(&Category::Outdated), Some(&2));
assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
assert_eq!(summary.get(&Category::License), None);
}
#[test]
fn test_check_report_summary_empty_for_no_findings() {
let report = CheckReport::default();
assert!(report.summary().is_empty());
}
const STUB_FORMATTER: deps_core::test_util::StubFormatter =
deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
let diagnostic =
Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
match code {
Some(code) => diagnostic.with_code(code),
None => diagnostic,
}
}
#[test]
fn test_classify_unsatisfiable_by_code() {
let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Unsatisfiable);
}
#[test]
fn test_classify_license_by_code() {
let d = diagnostic_with(
Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
"GPL-3.0 denied",
);
assert_eq!(classify(&d, &STUB_FORMATTER), Category::License);
}
#[test]
fn test_classify_deprecated_by_code() {
let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Deprecated);
}
#[test]
fn test_classify_mutable_ref_pin_by_code() {
let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::MutableRefPin);
let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::MutableRefPin);
}
#[test]
fn test_classify_sha_comment_mismatch_is_other() {
let d = diagnostic_with(
Some(GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE),
"SHA is not the commit of the tag in the comment",
);
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
}
#[test]
fn test_classify_advisory_code_is_vulnerable() {
let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Vulnerable);
}
#[test]
fn test_classify_outdated_by_message_prefix() {
let d = diagnostic_with(None, "Newer version available: 2.0.0");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Outdated);
}
#[test]
fn test_classify_yanked_by_formatter_message() {
let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Yanked);
}
#[test]
fn test_classify_unknown_package_is_other() {
let d = diagnostic_with(None, "Unknown package 'left-pad'");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
}
#[test]
fn test_classify_advisory_overflow_summary_is_vulnerable() {
let d = diagnostic_with(None, "+5 more advisories");
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Vulnerable);
}
#[test]
fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
let d = diagnostic_with(
Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
"registries.gitlab_instance_host is unset; skipping component/project host resolution",
);
assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
}
fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
deps_core::test_util::stub_parse_result_with_dependencies(1)
}
fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
deps_core::test_util::stub_parse_result_with_dependencies(0)
}
struct NamedFixtureDep {
name: PackageName,
requirement: Option<deps_core::VersionReq>,
}
impl deps_core::Dependency for NamedFixtureDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
self.requirement.as_ref()
}
fn version_range(&self) -> Option<Range> {
None
}
fn source(&self) -> deps_core::parser::DependencySource {
deps_core::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
struct NamedFixtureParseResult {
dep: NamedFixtureDep,
uri: url::Url,
}
impl deps_core::ParseResult for NamedFixtureParseResult {
fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
vec![&self.dep]
}
fn workspace_root(&self) -> Option<&Path> {
None
}
fn uri(&self) -> &url::Url {
&self.uri
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn dep_index_with_named_dependency(name: &str) -> Box<dyn deps_core::ParseResult> {
Box::new(NamedFixtureParseResult {
dep: NamedFixtureDep {
name: PackageName::new(name),
requirement: None,
},
uri: "file:///project/manifest.toml".parse().expect("valid URI"),
})
}
fn dep_index_with_named_dependency_and_requirement(
name: &str,
requirement: &str,
) -> Box<dyn deps_core::ParseResult> {
Box::new(NamedFixtureParseResult {
dep: NamedFixtureDep {
name: PackageName::new(name),
requirement: Some(deps_core::VersionReq::new(requirement)),
},
uri: "file:///project/manifest.toml".parse().expect("valid URI"),
})
}
#[test]
fn test_to_finding_extracts_string_code_from_diagnostic() {
let parse_result = empty_dep_index();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
}
#[test]
fn test_to_finding_code_is_none_without_a_diagnostic_code() {
let parse_result = empty_dep_index();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
assert!(finding.code.is_none());
}
#[test]
fn test_to_finding_attributes_outdated_to_gossip_when_excluded() {
let parse_result = dep_index_with_named_dependency("serde");
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Newer version available: 1.0.0");
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("serde"),
deps_core::lsp_helpers::PackageVersions::latest_only("1.0.0")
.with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
);
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&cached_versions,
);
assert_eq!(finding.category, Category::Outdated);
assert!(
finding.message.contains("GOSSIP"),
"message must attribute the exclusion to GOSSIP: {:?}",
finding.message
);
}
#[test]
fn test_to_finding_does_not_attribute_when_no_gossip_exclusion() {
let parse_result = dep_index_with_named_dependency("serde");
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Newer version available: 1.0.0");
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("serde"),
deps_core::lsp_helpers::PackageVersions::latest_only("1.0.0"),
);
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&cached_versions,
);
assert_eq!(finding.category, Category::Outdated);
assert!(!finding.message.contains("GOSSIP"));
}
#[test]
fn test_to_finding_extracts_advisory_url_from_code_description() {
let parse_result = empty_dep_index();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
.parse()
.expect("valid URL");
let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
.with_code_description(deps_core::diagnostic::CodeDescription::new(href));
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
assert_eq!(
finding.advisory_url.as_deref(),
Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
);
}
#[test]
fn test_to_finding_advisory_url_is_none_without_code_description() {
let parse_result = empty_dep_index();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
assert!(finding.advisory_url.is_none());
}
#[test]
fn test_to_finding_redacts_credential_shaped_dependency_name() {
let parse_result = dep_index_with_named_dependency(
"https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
);
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Unknown package");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
let name = finding
.dependency_name
.expect("range matched the dependency");
assert!(name.contains("***@"));
assert!(!name.contains("glpat-AAAABBBBCCCCDDDD"));
}
#[test]
fn test_to_sarif_fingerprint_never_carries_a_credential_through_to_finding() {
let parse_result = dep_index_with_named_dependency(
"https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
);
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Unknown package");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
let sarif = crate::format::sarif::to_sarif(&CheckReport {
findings: vec![finding],
});
let fp = sarif.runs[0].results.as_ref().expect("one result")[0]
.partial_fingerprints
.as_ref()
.expect("fingerprint set")
.get("depsCli/v1")
.expect("depsCli/v1 fingerprint key")
.clone();
assert!(
!fp.contains("glpat-AAAABBBBCCCCDDDD"),
"token leaked (raw or percent-encoded, since `-` is never percent-escaped): {fp}"
);
let decoded = urlencoding::decode(&fp).expect("fingerprint is valid percent-encoding");
assert!(
decoded.contains("***@gitlab.corp"),
"expected the redacted '***@host' marker, got: {decoded}"
);
}
#[test]
fn test_to_finding_sanitizes_manifest_path_ansi_and_bidi() {
let malicious_path = Path::new("src/\u{202E}\x1Bsneaky/Cargo.toml");
let parse_result = empty_dep_index();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
let finding = to_finding(
EcosystemId::Cargo,
malicious_path,
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
let sanitized = finding.manifest_path.to_string_lossy().into_owned();
assert!(
!sanitized.contains('\u{202E}'),
"bidi override survived sanitization: {sanitized:?}"
);
assert!(
!sanitized.contains('\x1B'),
"raw ANSI escape byte survived sanitization: {sanitized:?}"
);
assert!(sanitized.contains("src"), "legitimate path info lost");
assert!(
sanitized.contains("Cargo.toml"),
"legitimate path info lost"
);
let report = CheckReport {
findings: vec![finding],
};
let table = crate::format::table::render(&report);
assert!(
!table.contains('\u{202E}'),
"bidi override reached table output"
);
assert!(
!table.contains('\x1B'),
"raw ANSI escape byte reached table output"
);
let json = crate::format::json::to_document(&report);
let manifest_path_json = &json.findings[0].manifest_path;
assert!(
!manifest_path_json.contains('\u{202E}'),
"bidi override reached JSON output"
);
assert!(
!manifest_path_json.contains('\x1B'),
"raw ANSI escape byte reached JSON output"
);
assert!(manifest_path_json.contains("Cargo.toml"));
}
#[test]
fn test_to_finding_redacts_credential_shaped_requirement() {
let parse_result = dep_index_with_named_dependency_and_requirement(
"some-pkg",
"https://svcacct:hunter2@gitlab.corp/g/p.git",
);
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
let requirement = finding.requirement.expect("range matched the dependency");
assert!(requirement.contains("***@"));
assert!(!requirement.contains("hunter2"));
}
#[test]
fn test_to_finding_sanitizes_ansi_and_bidi_in_requirement_json_sink() {
let parse_result =
dep_index_with_named_dependency_and_requirement("some-pkg", "^1.0\u{202E}\x1B[31m");
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&VulnKeys::default(),
&HashMap::new(),
);
let report = CheckReport {
findings: vec![finding],
};
let json = crate::format::json::to_document(&report);
let requirement_json = json.findings[0]
.requirement
.as_deref()
.expect("range matched the dependency");
assert!(
!requirement_json.contains('\u{202E}'),
"bidi override reached JSON output"
);
assert!(
!requirement_json.contains('\x1B'),
"raw ANSI escape byte reached JSON output"
);
assert!(
requirement_json.starts_with("^1.0"),
"legitimate requirement info lost"
);
}
#[test]
fn test_to_finding_resolves_advisory_severity_from_index() {
let parse_result = dep_index_with_one_dependency();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
let vuln_keys = deps_core::osv::vulnerability_keys(
parse_result.as_ref(),
&HashMap::new(),
None,
&STUB_FORMATTER,
EcosystemId::Cargo,
);
let mut severities = HashMap::new();
severities.insert(
(
deps_core::test_util::vuln_key("dep-0"),
"RUSTSEC-2024-0001".to_string(),
),
VulnSeverity::Critical,
);
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&severities,
&vuln_keys,
&HashMap::new(),
);
assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
}
#[test]
fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
let parse_result = dep_index_with_one_dependency();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
let vuln_keys = deps_core::osv::vulnerability_keys(
parse_result.as_ref(),
&HashMap::new(),
None,
&STUB_FORMATTER,
EcosystemId::Cargo,
);
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&HashMap::new(),
&vuln_keys,
&HashMap::new(),
);
assert!(finding.advisory_severity.is_none());
}
#[test]
fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
let parse_result = empty_dep_index();
let dep_index = DependencyIndex::build(parse_result.as_ref());
let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
let mut severities = HashMap::new();
severities.insert(
(
deps_core::test_util::vuln_key("dep-0"),
"RUSTSEC-2024-0001".to_string(),
),
VulnSeverity::Critical,
);
let finding = to_finding(
EcosystemId::Cargo,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostic,
&severities,
&VulnKeys::default(),
&HashMap::new(),
);
assert!(finding.advisory_severity.is_none());
}
#[test]
fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
use std::sync::Arc;
let advisory = Advisory::new(
"RUSTSEC-2024-0001".to_string(),
"2024-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id");
let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
let mut map: VulnerabilityMap = HashMap::new();
map.insert(
deps_core::test_util::vuln_key("serde"),
ScanOutcome::Vulnerable(dv),
);
let index = advisory_severity_index(Some(&map));
assert_eq!(
index.get(&(
deps_core::test_util::vuln_key("serde"),
"RUSTSEC-2024-0001".to_string()
)),
Some(&VulnSeverity::High)
);
}
#[test]
fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
use std::sync::Arc;
let advisory_for = |severity: VulnSeverity| {
Arc::new(
Advisory::new(
"GHSA-shared-id".to_string(),
"2024-01-01T00:00:00Z".to_string(),
severity,
)
.expect("valid osv id"),
)
};
let mut map: VulnerabilityMap = HashMap::new();
map.insert(
deps_core::test_util::vuln_key("package-a"),
ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
vec![advisory_for(VulnSeverity::Critical)],
1,
))),
);
map.insert(
deps_core::test_util::vuln_key("package-b"),
ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
vec![advisory_for(VulnSeverity::Low)],
1,
))),
);
let index = advisory_severity_index(Some(&map));
assert_eq!(
index.get(&(
deps_core::test_util::vuln_key("package-a"),
"GHSA-shared-id".to_string()
)),
Some(&VulnSeverity::Critical)
);
assert_eq!(
index.get(&(
deps_core::test_util::vuln_key("package-b"),
"GHSA-shared-id".to_string()
)),
Some(&VulnSeverity::Low)
);
}
#[test]
fn test_advisory_severity_index_empty_without_vulnerabilities() {
assert!(advisory_severity_index(None).is_empty());
}
#[test]
fn test_check_pipeline_surfaces_gossip_cooldown_wording_via_version_data() {
use crate::analyze::ManifestAnalysis;
use deps_core::position::{Position, Range as PosRange};
use deps_core::test_util::stub_gossip_findings;
use deps_core::{Dependency, GossipCooldown, GossipRiskLevel, PublishTime};
use std::any::Any;
use std::collections::HashSet;
struct FixtureDep {
name: PackageName,
version_req: deps_core::VersionReq,
version_range: PosRange,
}
impl Dependency for FixtureDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> PosRange {
PosRange::new(Position::new(0, 0), Position::new(0, 5))
}
fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
Some(&self.version_req)
}
fn version_range(&self) -> Option<PosRange> {
Some(self.version_range)
}
fn source(&self) -> deps_core::parser::DependencySource {
deps_core::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct FixtureParseResult {
dep: FixtureDep,
uri: url::Url,
}
impl deps_core::ParseResult for FixtureParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
vec![&self.dep]
}
fn workspace_root(&self) -> Option<&Path> {
None
}
fn uri(&self) -> &url::Url {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
let uri: url::Url = "file:///project/Cargo.toml".parse().expect("valid URI");
let parse_result: Box<dyn deps_core::ParseResult> = Box::new(FixtureParseResult {
dep: FixtureDep {
name: PackageName::new("serde"),
version_req: deps_core::VersionReq::new("1.0"),
version_range: PosRange::new(Position::new(0, 10), Position::new(0, 20)),
},
uri: uri.clone(),
});
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("serde"),
deps_core::lsp_helpers::PackageVersions::latest_only("2.0.0"),
);
let mut gossip_findings = HashMap::new();
gossip_findings.insert(
PackageName::new("serde"),
stub_gossip_findings(
"2.0.0",
Some(GossipCooldown::new(
PublishTime::from_unix_secs(PublishTime::now().as_unix_secs() + 1_000),
GossipRiskLevel::High,
)),
),
);
let analysis = ManifestAnalysis {
parse_result,
uri: uri.clone(),
now: PublishTime::now(),
ecosystem_id: EcosystemId::Cargo,
cached_versions: cached_versions.clone(),
resolved_versions: HashMap::new(),
resolved_version_candidates: HashMap::new(),
outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
vulnerabilities: None,
latest_status: None,
fallback_status: None,
cooldown_fallback_view: None,
gossip_findings,
licenses: HashMap::new(),
license_policy: deps_core::licenses::LicensePolicy::default(),
license_source: deps_core::LicenseSource::default(),
network: deps_core::NetworkMode::Online,
fetch_failed: HashSet::new(),
registry_unreachable: false,
license_fetch_incomplete: false,
};
let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
analysis.parse_result.as_ref(),
analysis.version_data(),
&STUB_FORMATTER,
&uri,
deps_core::FreshnessSettings::default(),
deps_core::lsp_helpers::DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert!(
diagnostics[0].message().contains("deps.dev/GOSSIP"),
"check's diagnostic generation must consult live GOSSIP prefetch data: {}",
diagnostics[0].message()
);
let dep_index = DependencyIndex::build(analysis.parse_result.as_ref());
let finding = to_finding(
analysis.ecosystem_id,
Path::new("Cargo.toml"),
&dep_index,
&STUB_FORMATTER,
diagnostics.into_iter().next().expect("one diagnostic"),
&HashMap::new(),
&VulnKeys::default(),
&cached_versions,
);
assert_eq!(finding.category, Category::Outdated);
assert!(
finding.message.contains("deps.dev/GOSSIP"),
"the GOSSIP wording must survive into the SARIF finding: {}",
finding.message
);
}
}