1use deps_core::diagnostic::{Diagnostic, Severity};
14use deps_core::lsp_helpers::{
15 DEPRECATED_DIAGNOSTIC_CODE, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
16 UNSATISFIABLE_DIAGNOSTIC_CODE, redact_name_for_diagnostic, redact_requirement_for_diagnostic,
17};
18use deps_core::osv::{OsvClient, ScanOutcome, VulnKeys, VulnSeverity, VulnerabilityMap};
19use deps_core::policy_config::PolicyConfig;
20use deps_core::position::Range;
21use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache};
22use std::collections::{BTreeMap, HashMap};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
38const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
40const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
45const GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE: &str = "sha-comment-mismatch";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
76pub enum Category {
77 Outdated,
79 Yanked,
81 Vulnerable,
83 Unsatisfiable,
85 #[value(name = "mutable-ref")]
88 MutableRefPin,
89 License,
91 Deprecated,
93 #[value(skip)]
95 Other,
96}
97
98impl Category {
99 #[must_use]
101 pub const fn as_str(self) -> &'static str {
102 match self {
103 Self::Outdated => "outdated",
104 Self::Yanked => "yanked",
105 Self::Vulnerable => "vulnerable",
106 Self::Unsatisfiable => "unsatisfiable",
107 Self::MutableRefPin => "mutable-ref",
108 Self::License => "license",
109 Self::Deprecated => "deprecated",
110 Self::Other => "other",
111 }
112 }
113
114 #[must_use]
136 pub const fn description(self) -> &'static str {
137 match self {
138 Self::Outdated => {
139 "A newer version is published for the dependency's declared requirement."
140 }
141 Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
142 Self::Vulnerable => "A known security advisory affects the in-use version.",
143 Self::Unsatisfiable => "No published version satisfies the declared requirement.",
144 Self::MutableRefPin => {
145 "Pinned to a mutable ref (tag or branch) instead of a commit SHA."
146 }
147 Self::License => "The resolved license violates the configured allow/deny policy.",
148 Self::Deprecated => {
149 "The registry reports the package itself as deprecated or abandoned."
150 }
151 Self::Other => "A finding that does not map to any of the other categories.",
152 }
153 }
154}
155
156impl std::fmt::Display for Category {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.write_str(self.as_str())
159 }
160}
161
162impl serde::Serialize for Category {
172 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
173 serializer.serialize_str(self.as_str())
174 }
175}
176
177impl<'de> serde::Deserialize<'de> for Category {
178 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
179 let token = String::deserialize(deserializer)?;
180 match token.as_str() {
181 "outdated" => Ok(Self::Outdated),
182 "yanked" => Ok(Self::Yanked),
183 "vulnerable" => Ok(Self::Vulnerable),
184 "unsatisfiable" => Ok(Self::Unsatisfiable),
185 "mutable-ref" => Ok(Self::MutableRefPin),
186 "license" => Ok(Self::License),
187 "deprecated" => Ok(Self::Deprecated),
188 "other" => Ok(Self::Other),
189 other => Err(serde::de::Error::unknown_variant(
190 other,
191 &[
192 "outdated",
193 "yanked",
194 "vulnerable",
195 "unsatisfiable",
196 "mutable-ref",
197 "license",
198 "deprecated",
199 "other",
200 ],
201 )),
202 }
203 }
204}
205
206#[derive(Debug, Clone)]
208pub struct CheckFinding {
209 pub ecosystem: EcosystemId,
211 pub manifest_path: PathBuf,
218 pub dependency_name: Option<String>,
224 pub requirement: Option<String>,
228 pub category: Category,
230 pub code: Option<String>,
238 pub advisory_url: Option<String>,
247 pub advisory_severity: Option<VulnSeverity>,
255 pub severity: Severity,
257 pub range: Range,
259 pub message: String,
261}
262
263#[derive(Debug, Clone, Default)]
265pub struct CheckReport {
266 pub findings: Vec<CheckFinding>,
268}
269
270impl CheckReport {
271 #[must_use]
301 pub fn summary(&self) -> BTreeMap<Category, usize> {
302 let mut counts = BTreeMap::new();
303 for finding in &self.findings {
304 *counts.entry(finding.category).or_insert(0_usize) += 1;
305 }
306 counts
307 }
308}
309
310#[derive(Debug, Clone)]
313pub struct FailOnPolicy {
314 categories: Vec<Category>,
315}
316
317impl FailOnPolicy {
318 #[must_use]
320 pub const fn new(categories: Vec<Category>) -> Self {
321 Self { categories }
322 }
323
324 #[must_use]
337 pub fn default_categories() -> Self {
338 Self::new(vec![
339 Category::Vulnerable,
340 Category::Yanked,
341 Category::Unsatisfiable,
342 ])
343 }
344
345 #[must_use]
347 pub fn categories(&self) -> &[Category] {
348 &self.categories
349 }
350
351 #[must_use]
353 pub fn matches(&self, findings: &[CheckFinding]) -> bool {
354 findings
355 .iter()
356 .any(|finding| self.categories.contains(&finding.category))
357 }
358}
359
360impl Default for FailOnPolicy {
361 fn default() -> Self {
362 Self::default_categories()
363 }
364}
365
366#[derive(Clone)]
371pub struct CheckContext {
372 pub cache: Arc<HttpCache>,
374 pub osv: Arc<OsvClient>,
376 pub deps_dev: Arc<deps_core::DepsDevClient>,
381 pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
383 pub policy: PolicyConfig,
385}
386
387#[derive(Debug, thiserror::Error)]
389pub enum CheckError {
390 #[error("failed to parse {path}: {source}")]
392 Parse {
393 path: PathBuf,
395 #[source]
397 source: deps_core::DepsError,
398 },
399 #[error("could not build a file URI for {path}")]
402 InvalidPath {
403 path: PathBuf,
405 },
406}
407
408#[derive(Debug, Clone)]
410pub struct ManifestCheckResult {
411 pub findings: Vec<CheckFinding>,
413 pub registry_unreachable: bool,
424 pub license_fetch_incomplete: bool,
432}
433
434pub async fn check_manifest(
451 ecosystem: &Arc<dyn Ecosystem>,
452 manifest_path: &Path,
453 display_path: &Path,
454 content: &str,
455 ctx: &CheckContext,
456) -> Result<ManifestCheckResult, CheckError> {
457 let analysis = crate::analyze::analyze_manifest(
458 ecosystem,
459 manifest_path,
460 content,
461 ctx,
462 crate::analyze::AnalysisScope::all(),
463 )
464 .await?;
465 let formatter = ecosystem.formatter();
466
467 let severities = ctx.policy.diagnostics.to_severities();
468 let diagnostics = ecosystem
469 .generate_diagnostics(
470 analysis.parse_result.as_ref(),
471 analysis.version_data(),
472 &analysis.uri,
473 ctx.policy.freshness.to_freshness(),
474 severities,
475 )
476 .await;
477
478 let dep_index = DependencyIndex::build(analysis.parse_result.as_ref());
479 let advisory_severities = advisory_severity_index(analysis.vulnerabilities.as_ref());
481 let vuln_keys = deps_core::osv::vulnerability_keys(
484 analysis.parse_result.as_ref(),
485 &analysis.resolved_versions,
486 Some(&analysis.resolved_version_candidates),
487 formatter,
488 analysis.ecosystem_id,
489 );
490 let findings = diagnostics
491 .into_iter()
492 .map(|diagnostic| {
493 to_finding(
494 analysis.ecosystem_id,
495 display_path,
496 &dep_index,
497 formatter,
498 diagnostic,
499 &advisory_severities,
500 &vuln_keys,
501 &analysis.cached_versions,
502 )
503 })
504 .collect();
505 Ok(ManifestCheckResult {
506 findings,
507 registry_unreachable: analysis.registry_unreachable,
508 license_fetch_incomplete: analysis.license_fetch_incomplete,
509 })
510}
511
512struct DependencyIndex<'a> {
522 by_name_range: HashMap<Range, &'a dyn Dependency>,
523 by_version_range: HashMap<Range, &'a dyn Dependency>,
524}
525
526impl<'a> DependencyIndex<'a> {
527 fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
528 let mut by_name_range = HashMap::new();
529 let mut by_version_range = HashMap::new();
530 for dep in parse_result.dependencies() {
531 if !dep.name_range_is_synthetic() {
532 by_name_range.insert(dep.name_range(), dep);
533 }
534 if let Some(version_range) = dep.version_range() {
535 by_version_range.insert(version_range, dep);
536 }
537 }
538 Self {
539 by_name_range,
540 by_version_range,
541 }
542 }
543
544 fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
548 self.by_name_range
549 .get(&range)
550 .or_else(|| self.by_version_range.get(&range))
551 .copied()
552 }
553}
554
555fn advisory_severity_index(
573 vulnerabilities: Option<&VulnerabilityMap>,
574) -> HashMap<(deps_core::osv::VulnKey, String), VulnSeverity> {
575 let mut index = HashMap::new();
576 let Some(vulnerabilities) = vulnerabilities else {
577 return index;
578 };
579 for (dependency_key, outcome) in vulnerabilities {
580 if let ScanOutcome::Vulnerable(dv) = outcome {
581 for advisory in dv.advisories.items() {
582 index.insert(
583 (dependency_key.clone(), advisory.id.clone()),
584 advisory.severity,
585 );
586 }
587 }
588 }
589 index
590}
591
592#[allow(
598 clippy::too_many_arguments,
599 reason = "internal (non-pub) call-site-controlled classification/attribution inputs; \
600 grouping into a struct would only move, not reduce, the single production \
601 call site's churn"
602)]
603fn to_finding(
604 ecosystem: EcosystemId,
605 display_path: &Path,
606 dep_index: &DependencyIndex<'_>,
607 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
608 diagnostic: Diagnostic,
609 advisory_severities: &HashMap<(deps_core::osv::VulnKey, String), VulnSeverity>,
610 vuln_keys: &VulnKeys,
611 cached_versions: &HashMap<deps_core::PackageName, deps_core::lsp_helpers::PackageVersions>,
612) -> CheckFinding {
613 let category = classify(&diagnostic, formatter);
614 let dep = dep_index.lookup(diagnostic.range);
615 let code = diagnostic.code().map(str::to_string);
616 let advisory_url = diagnostic
617 .code_description
618 .as_ref()
619 .map(|code_description| code_description.href.as_str().to_string());
620 let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
621 if dep.name_range_is_synthetic() {
623 return None;
624 }
625 let dependency_key = deps_core::osv::vuln_key_for(dep, Some(vuln_keys), formatter);
626 advisory_severities
627 .get(&(dependency_key, code.to_string()))
628 .copied()
629 });
630 let mut message = diagnostic.message().to_string();
637 if category == Category::Outdated
638 && let Some(dep) = dep
639 && cached_versions
640 .get(dep.name())
641 .is_some_and(|v| v.gossip_excluded_version.is_some())
642 {
643 message.push_str(
644 " (a newer version was excluded from this pick by an active GOSSIP cooldown finding)",
645 );
646 }
647 CheckFinding {
648 ecosystem,
649 manifest_path: crate::sanitize::sanitize_path_for_display(display_path),
650 dependency_name: dep.map(|d| redact_name_for_diagnostic(d.name())),
651 requirement: dep
652 .and_then(Dependency::version_requirement)
653 .map(redact_requirement_for_diagnostic),
654 category,
655 code,
656 advisory_url,
657 advisory_severity,
658 severity: diagnostic.severity.unwrap_or(Severity::Warning),
659 range: diagnostic.range,
660 message,
661 }
662}
663
664fn classify(
681 diagnostic: &Diagnostic,
682 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
683) -> Category {
684 if let Some(code) = diagnostic.code() {
685 return match code {
686 UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
687 LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
688 DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
689 GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
690 Category::MutableRefPin
691 }
692 GITLAB_CI_UNRESOLVED_HOST_CODE | GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE => {
693 Category::Other
694 }
695 _ => Category::Vulnerable,
696 };
697 }
698 if diagnostic.message().starts_with("Newer version available") {
699 return Category::Outdated;
700 }
701 if diagnostic.message().contains(formatter.yanked_message()) {
702 return Category::Yanked;
703 }
704 if diagnostic.message().ends_with("more advisories") {
707 return Category::Vulnerable;
708 }
709 Category::Other
710}
711
712pub(crate) fn path_to_uri(path: &Path) -> Option<url::Url> {
720 let absolute = if path.is_absolute() {
721 path.to_path_buf()
722 } else {
723 std::env::current_dir()
724 .map(|cwd| cwd.join(path))
725 .unwrap_or_else(|_| path.to_path_buf())
726 };
727 url::Url::from_file_path(&absolute).ok()
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use deps_core::PackageName;
734
735 fn finding(category: Category) -> CheckFinding {
736 CheckFinding {
737 ecosystem: EcosystemId::Cargo,
738 manifest_path: PathBuf::from("Cargo.toml"),
739 dependency_name: Some("serde".to_string()),
740 requirement: Some("1.0".to_string()),
741 category,
742 code: None,
743 advisory_url: None,
744 advisory_severity: None,
745 severity: Severity::Warning,
746 range: Range::default(),
747 message: "test".to_string(),
748 }
749 }
750
751 #[test]
752 fn test_category_as_str_matches_fr009_tokens() {
753 assert_eq!(Category::Outdated.as_str(), "outdated");
754 assert_eq!(Category::Yanked.as_str(), "yanked");
755 assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
756 assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
757 assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
758 assert_eq!(Category::License.as_str(), "license");
759 assert_eq!(Category::Deprecated.as_str(), "deprecated");
760 assert_eq!(Category::Other.as_str(), "other");
761 }
762
763 #[test]
766 fn test_category_serialize_matches_as_str_tokens() {
767 for category in [
768 Category::Outdated,
769 Category::Yanked,
770 Category::Vulnerable,
771 Category::Unsatisfiable,
772 Category::MutableRefPin,
773 Category::License,
774 Category::Deprecated,
775 Category::Other,
776 ] {
777 let json = serde_json::to_string(&category).expect("Category must serialize");
778 assert_eq!(json, format!("\"{}\"", category.as_str()));
779 let parsed: Category = serde_json::from_str(&json).expect("must round-trip");
780 assert_eq!(parsed, category);
781 }
782 }
783
784 #[test]
788 fn test_category_deserialize_rejects_unknown_token() {
789 let result: Result<Category, _> = serde_json::from_str("\"not-a-real-category\"");
790 assert!(result.is_err());
791 }
792
793 #[test]
794 fn test_fail_on_policy_default_categories() {
795 let policy = FailOnPolicy::default_categories();
796 assert!(policy.matches(&[finding(Category::Vulnerable)]));
797 assert!(policy.matches(&[finding(Category::Yanked)]));
798 assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
799 assert!(!policy.matches(&[finding(Category::Outdated)]));
800 assert!(!policy.matches(&[finding(Category::License)]));
801 assert!(!policy.matches(&[finding(Category::Deprecated)]));
802 assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
803 assert!(!policy.matches(&[finding(Category::Other)]));
804 }
805
806 #[test]
807 fn test_fail_on_policy_custom_categories() {
808 let policy = FailOnPolicy::new(vec![Category::License]);
809 assert!(policy.matches(&[finding(Category::License)]));
810 assert!(!policy.matches(&[finding(Category::Vulnerable)]));
811 }
812
813 #[test]
814 fn test_fail_on_policy_empty_findings_never_matches() {
815 assert!(!FailOnPolicy::default_categories().matches(&[]));
816 }
817
818 #[test]
819 fn test_check_report_summary_counts_per_category() {
820 let report = CheckReport {
821 findings: vec![
822 finding(Category::Outdated),
823 finding(Category::Outdated),
824 finding(Category::Vulnerable),
825 ],
826 };
827 let summary = report.summary();
828 assert_eq!(summary.get(&Category::Outdated), Some(&2));
829 assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
830 assert_eq!(summary.get(&Category::License), None);
831 }
832
833 #[test]
834 fn test_check_report_summary_empty_for_no_findings() {
835 let report = CheckReport::default();
836 assert!(report.summary().is_empty());
837 }
838
839 const STUB_FORMATTER: deps_core::test_util::StubFormatter =
840 deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
841
842 fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
843 let diagnostic =
844 Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
845 match code {
846 Some(code) => diagnostic.with_code(code),
847 None => diagnostic,
848 }
849 }
850
851 #[test]
852 fn test_classify_unsatisfiable_by_code() {
853 let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
854 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Unsatisfiable);
855 }
856
857 #[test]
858 fn test_classify_license_by_code() {
859 let d = diagnostic_with(
860 Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
861 "GPL-3.0 denied",
862 );
863 assert_eq!(classify(&d, &STUB_FORMATTER), Category::License);
864 }
865
866 #[test]
867 fn test_classify_deprecated_by_code() {
868 let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
869 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Deprecated);
870 }
871
872 #[test]
873 fn test_classify_mutable_ref_pin_by_code() {
874 let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
875 assert_eq!(classify(&d, &STUB_FORMATTER), Category::MutableRefPin);
876 let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
877 assert_eq!(classify(&d, &STUB_FORMATTER), Category::MutableRefPin);
878 }
879
880 #[test]
881 fn test_classify_sha_comment_mismatch_is_other() {
882 let d = diagnostic_with(
883 Some(GITHUB_ACTIONS_SHA_COMMENT_MISMATCH_CODE),
884 "SHA is not the commit of the tag in the comment",
885 );
886 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
887 }
888
889 #[test]
890 fn test_classify_advisory_code_is_vulnerable() {
891 let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
892 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Vulnerable);
893 }
894
895 #[test]
896 fn test_classify_outdated_by_message_prefix() {
897 let d = diagnostic_with(None, "Newer version available: 2.0.0");
898 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Outdated);
899 }
900
901 #[test]
902 fn test_classify_yanked_by_formatter_message() {
903 let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
904 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Yanked);
905 }
906
907 #[test]
908 fn test_classify_unknown_package_is_other() {
909 let d = diagnostic_with(None, "Unknown package 'left-pad'");
910 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
911 }
912
913 #[test]
916 fn test_classify_advisory_overflow_summary_is_vulnerable() {
917 let d = diagnostic_with(None, "+5 more advisories");
918 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Vulnerable);
919 }
920
921 #[test]
925 fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
926 let d = diagnostic_with(
927 Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
928 "registries.gitlab_instance_host is unset; skipping component/project host resolution",
929 );
930 assert_eq!(classify(&d, &STUB_FORMATTER), Category::Other);
931 }
932
933 fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
940 deps_core::test_util::stub_parse_result_with_dependencies(1)
941 }
942
943 fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
944 deps_core::test_util::stub_parse_result_with_dependencies(0)
945 }
946
947 struct NamedFixtureDep {
953 name: PackageName,
954 requirement: Option<deps_core::VersionReq>,
955 }
956
957 impl deps_core::Dependency for NamedFixtureDep {
958 fn name(&self) -> &PackageName {
959 &self.name
960 }
961 fn name_range(&self) -> Range {
962 Range::default()
963 }
964 fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
965 self.requirement.as_ref()
966 }
967 fn version_range(&self) -> Option<Range> {
968 None
969 }
970 fn source(&self) -> deps_core::parser::DependencySource {
971 deps_core::parser::DependencySource::Registry
972 }
973 fn as_any(&self) -> &dyn std::any::Any {
974 self
975 }
976 }
977
978 struct NamedFixtureParseResult {
979 dep: NamedFixtureDep,
980 uri: url::Url,
981 }
982
983 impl deps_core::ParseResult for NamedFixtureParseResult {
984 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
985 vec![&self.dep]
986 }
987 fn workspace_root(&self) -> Option<&Path> {
988 None
989 }
990 fn uri(&self) -> &url::Url {
991 &self.uri
992 }
993 fn as_any(&self) -> &dyn std::any::Any {
994 self
995 }
996 }
997
998 fn dep_index_with_named_dependency(name: &str) -> Box<dyn deps_core::ParseResult> {
999 Box::new(NamedFixtureParseResult {
1000 dep: NamedFixtureDep {
1001 name: PackageName::new(name),
1002 requirement: None,
1003 },
1004 uri: "file:///project/manifest.toml".parse().expect("valid URI"),
1005 })
1006 }
1007
1008 fn dep_index_with_named_dependency_and_requirement(
1011 name: &str,
1012 requirement: &str,
1013 ) -> Box<dyn deps_core::ParseResult> {
1014 Box::new(NamedFixtureParseResult {
1015 dep: NamedFixtureDep {
1016 name: PackageName::new(name),
1017 requirement: Some(deps_core::VersionReq::new(requirement)),
1018 },
1019 uri: "file:///project/manifest.toml".parse().expect("valid URI"),
1020 })
1021 }
1022
1023 #[test]
1027 fn test_to_finding_extracts_string_code_from_diagnostic() {
1028 let parse_result = empty_dep_index();
1029 let dep_index = DependencyIndex::build(parse_result.as_ref());
1030 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1031 let finding = to_finding(
1032 EcosystemId::Cargo,
1033 Path::new("Cargo.toml"),
1034 &dep_index,
1035 &STUB_FORMATTER,
1036 diagnostic,
1037 &HashMap::new(),
1038 &VulnKeys::default(),
1039 &HashMap::new(),
1040 );
1041 assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
1042 }
1043
1044 #[test]
1045 fn test_to_finding_code_is_none_without_a_diagnostic_code() {
1046 let parse_result = empty_dep_index();
1047 let dep_index = DependencyIndex::build(parse_result.as_ref());
1048 let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1049 let finding = to_finding(
1050 EcosystemId::Cargo,
1051 Path::new("Cargo.toml"),
1052 &dep_index,
1053 &STUB_FORMATTER,
1054 diagnostic,
1055 &HashMap::new(),
1056 &VulnKeys::default(),
1057 &HashMap::new(),
1058 );
1059 assert!(finding.code.is_none());
1060 }
1061
1062 #[test]
1065 fn test_to_finding_attributes_outdated_to_gossip_when_excluded() {
1066 let parse_result = dep_index_with_named_dependency("serde");
1067 let dep_index = DependencyIndex::build(parse_result.as_ref());
1068 let diagnostic = diagnostic_with(None, "Newer version available: 1.0.0");
1069 let mut cached_versions = HashMap::new();
1070 cached_versions.insert(
1071 PackageName::new("serde"),
1072 deps_core::lsp_helpers::PackageVersions::latest_only("1.0.0")
1073 .with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
1074 );
1075 let finding = to_finding(
1076 EcosystemId::Cargo,
1077 Path::new("Cargo.toml"),
1078 &dep_index,
1079 &STUB_FORMATTER,
1080 diagnostic,
1081 &HashMap::new(),
1082 &VulnKeys::default(),
1083 &cached_versions,
1084 );
1085 assert_eq!(finding.category, Category::Outdated);
1086 assert!(
1087 finding.message.contains("GOSSIP"),
1088 "message must attribute the exclusion to GOSSIP: {:?}",
1089 finding.message
1090 );
1091 }
1092
1093 #[test]
1096 fn test_to_finding_does_not_attribute_when_no_gossip_exclusion() {
1097 let parse_result = dep_index_with_named_dependency("serde");
1098 let dep_index = DependencyIndex::build(parse_result.as_ref());
1099 let diagnostic = diagnostic_with(None, "Newer version available: 1.0.0");
1100 let mut cached_versions = HashMap::new();
1101 cached_versions.insert(
1102 PackageName::new("serde"),
1103 deps_core::lsp_helpers::PackageVersions::latest_only("1.0.0"),
1104 );
1105 let finding = to_finding(
1106 EcosystemId::Cargo,
1107 Path::new("Cargo.toml"),
1108 &dep_index,
1109 &STUB_FORMATTER,
1110 diagnostic,
1111 &HashMap::new(),
1112 &VulnKeys::default(),
1113 &cached_versions,
1114 );
1115 assert_eq!(finding.category, Category::Outdated);
1116 assert!(!finding.message.contains("GOSSIP"));
1117 }
1118
1119 #[test]
1120 fn test_to_finding_extracts_advisory_url_from_code_description() {
1121 let parse_result = empty_dep_index();
1122 let dep_index = DependencyIndex::build(parse_result.as_ref());
1123 let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
1124 .parse()
1125 .expect("valid URL");
1126 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
1127 .with_code_description(deps_core::diagnostic::CodeDescription::new(href));
1128 let finding = to_finding(
1129 EcosystemId::Cargo,
1130 Path::new("Cargo.toml"),
1131 &dep_index,
1132 &STUB_FORMATTER,
1133 diagnostic,
1134 &HashMap::new(),
1135 &VulnKeys::default(),
1136 &HashMap::new(),
1137 );
1138 assert_eq!(
1139 finding.advisory_url.as_deref(),
1140 Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
1141 );
1142 }
1143
1144 #[test]
1145 fn test_to_finding_advisory_url_is_none_without_code_description() {
1146 let parse_result = empty_dep_index();
1147 let dep_index = DependencyIndex::build(parse_result.as_ref());
1148 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1149 let finding = to_finding(
1150 EcosystemId::Cargo,
1151 Path::new("Cargo.toml"),
1152 &dep_index,
1153 &STUB_FORMATTER,
1154 diagnostic,
1155 &HashMap::new(),
1156 &VulnKeys::default(),
1157 &HashMap::new(),
1158 );
1159 assert!(finding.advisory_url.is_none());
1160 }
1161
1162 #[test]
1166 fn test_to_finding_redacts_credential_shaped_dependency_name() {
1167 let parse_result = dep_index_with_named_dependency(
1168 "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1169 );
1170 let dep_index = DependencyIndex::build(parse_result.as_ref());
1171 let diagnostic = diagnostic_with(None, "Unknown package");
1172
1173 let finding = to_finding(
1174 EcosystemId::Cargo,
1175 Path::new("Cargo.toml"),
1176 &dep_index,
1177 &STUB_FORMATTER,
1178 diagnostic,
1179 &HashMap::new(),
1180 &VulnKeys::default(),
1181 &HashMap::new(),
1182 );
1183
1184 let name = finding
1185 .dependency_name
1186 .expect("range matched the dependency");
1187 assert!(name.contains("***@"));
1188 assert!(!name.contains("glpat-AAAABBBBCCCCDDDD"));
1189 }
1190
1191 #[test]
1199 fn test_to_sarif_fingerprint_never_carries_a_credential_through_to_finding() {
1200 let parse_result = dep_index_with_named_dependency(
1201 "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1202 );
1203 let dep_index = DependencyIndex::build(parse_result.as_ref());
1204 let diagnostic = diagnostic_with(None, "Unknown package");
1205
1206 let finding = to_finding(
1207 EcosystemId::Cargo,
1208 Path::new("Cargo.toml"),
1209 &dep_index,
1210 &STUB_FORMATTER,
1211 diagnostic,
1212 &HashMap::new(),
1213 &VulnKeys::default(),
1214 &HashMap::new(),
1215 );
1216
1217 let sarif = crate::format::sarif::to_sarif(&CheckReport {
1218 findings: vec![finding],
1219 });
1220 let fp = sarif.runs[0].results.as_ref().expect("one result")[0]
1221 .partial_fingerprints
1222 .as_ref()
1223 .expect("fingerprint set")
1224 .get("depsCli/v1")
1225 .expect("depsCli/v1 fingerprint key")
1226 .clone();
1227
1228 assert!(
1229 !fp.contains("glpat-AAAABBBBCCCCDDDD"),
1230 "token leaked (raw or percent-encoded, since `-` is never percent-escaped): {fp}"
1231 );
1232 let decoded = urlencoding::decode(&fp).expect("fingerprint is valid percent-encoding");
1233 assert!(
1234 decoded.contains("***@gitlab.corp"),
1235 "expected the redacted '***@host' marker, got: {decoded}"
1236 );
1237 }
1238
1239 #[test]
1248 fn test_to_finding_sanitizes_manifest_path_ansi_and_bidi() {
1249 let malicious_path = Path::new("src/\u{202E}\x1Bsneaky/Cargo.toml");
1250 let parse_result = empty_dep_index();
1251 let dep_index = DependencyIndex::build(parse_result.as_ref());
1252 let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1253
1254 let finding = to_finding(
1255 EcosystemId::Cargo,
1256 malicious_path,
1257 &dep_index,
1258 &STUB_FORMATTER,
1259 diagnostic,
1260 &HashMap::new(),
1261 &VulnKeys::default(),
1262 &HashMap::new(),
1263 );
1264
1265 let sanitized = finding.manifest_path.to_string_lossy().into_owned();
1266 assert!(
1267 !sanitized.contains('\u{202E}'),
1268 "bidi override survived sanitization: {sanitized:?}"
1269 );
1270 assert!(
1271 !sanitized.contains('\x1B'),
1272 "raw ANSI escape byte survived sanitization: {sanitized:?}"
1273 );
1274 assert!(sanitized.contains("src"), "legitimate path info lost");
1275 assert!(
1276 sanitized.contains("Cargo.toml"),
1277 "legitimate path info lost"
1278 );
1279
1280 let report = CheckReport {
1281 findings: vec![finding],
1282 };
1283
1284 let table = crate::format::table::render(&report);
1285 assert!(
1286 !table.contains('\u{202E}'),
1287 "bidi override reached table output"
1288 );
1289 assert!(
1290 !table.contains('\x1B'),
1291 "raw ANSI escape byte reached table output"
1292 );
1293
1294 let json = crate::format::json::to_document(&report);
1295 let manifest_path_json = &json.findings[0].manifest_path;
1296 assert!(
1297 !manifest_path_json.contains('\u{202E}'),
1298 "bidi override reached JSON output"
1299 );
1300 assert!(
1301 !manifest_path_json.contains('\x1B'),
1302 "raw ANSI escape byte reached JSON output"
1303 );
1304 assert!(manifest_path_json.contains("Cargo.toml"));
1305 }
1306
1307 #[test]
1312 fn test_to_finding_redacts_credential_shaped_requirement() {
1313 let parse_result = dep_index_with_named_dependency_and_requirement(
1314 "some-pkg",
1315 "https://svcacct:hunter2@gitlab.corp/g/p.git",
1316 );
1317 let dep_index = DependencyIndex::build(parse_result.as_ref());
1318 let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1319
1320 let finding = to_finding(
1321 EcosystemId::Cargo,
1322 Path::new("Cargo.toml"),
1323 &dep_index,
1324 &STUB_FORMATTER,
1325 diagnostic,
1326 &HashMap::new(),
1327 &VulnKeys::default(),
1328 &HashMap::new(),
1329 );
1330
1331 let requirement = finding.requirement.expect("range matched the dependency");
1332 assert!(requirement.contains("***@"));
1333 assert!(!requirement.contains("hunter2"));
1334 }
1335
1336 #[test]
1347 fn test_to_finding_sanitizes_ansi_and_bidi_in_requirement_json_sink() {
1348 let parse_result =
1349 dep_index_with_named_dependency_and_requirement("some-pkg", "^1.0\u{202E}\x1B[31m");
1350 let dep_index = DependencyIndex::build(parse_result.as_ref());
1351 let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1352
1353 let finding = to_finding(
1354 EcosystemId::Cargo,
1355 Path::new("Cargo.toml"),
1356 &dep_index,
1357 &STUB_FORMATTER,
1358 diagnostic,
1359 &HashMap::new(),
1360 &VulnKeys::default(),
1361 &HashMap::new(),
1362 );
1363
1364 let report = CheckReport {
1365 findings: vec![finding],
1366 };
1367 let json = crate::format::json::to_document(&report);
1368 let requirement_json = json.findings[0]
1369 .requirement
1370 .as_deref()
1371 .expect("range matched the dependency");
1372
1373 assert!(
1374 !requirement_json.contains('\u{202E}'),
1375 "bidi override reached JSON output"
1376 );
1377 assert!(
1378 !requirement_json.contains('\x1B'),
1379 "raw ANSI escape byte reached JSON output"
1380 );
1381 assert!(
1382 requirement_json.starts_with("^1.0"),
1383 "legitimate requirement info lost"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_to_finding_resolves_advisory_severity_from_index() {
1389 let parse_result = dep_index_with_one_dependency();
1390 let dep_index = DependencyIndex::build(parse_result.as_ref());
1391 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1392
1393 let vuln_keys = deps_core::osv::vulnerability_keys(
1394 parse_result.as_ref(),
1395 &HashMap::new(),
1396 None,
1397 &STUB_FORMATTER,
1398 EcosystemId::Cargo,
1399 );
1400 let mut severities = HashMap::new();
1401 severities.insert(
1402 (
1403 deps_core::test_util::vuln_key("dep-0"),
1404 "RUSTSEC-2024-0001".to_string(),
1405 ),
1406 VulnSeverity::Critical,
1407 );
1408
1409 let finding = to_finding(
1410 EcosystemId::Cargo,
1411 Path::new("Cargo.toml"),
1412 &dep_index,
1413 &STUB_FORMATTER,
1414 diagnostic,
1415 &severities,
1416 &vuln_keys,
1417 &HashMap::new(),
1418 );
1419 assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
1420 }
1421
1422 #[test]
1423 fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
1424 let parse_result = dep_index_with_one_dependency();
1425 let dep_index = DependencyIndex::build(parse_result.as_ref());
1426 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1427 let vuln_keys = deps_core::osv::vulnerability_keys(
1428 parse_result.as_ref(),
1429 &HashMap::new(),
1430 None,
1431 &STUB_FORMATTER,
1432 EcosystemId::Cargo,
1433 );
1434
1435 let finding = to_finding(
1436 EcosystemId::Cargo,
1437 Path::new("Cargo.toml"),
1438 &dep_index,
1439 &STUB_FORMATTER,
1440 diagnostic,
1441 &HashMap::new(),
1442 &vuln_keys,
1443 &HashMap::new(),
1444 );
1445 assert!(finding.advisory_severity.is_none());
1446 }
1447
1448 #[test]
1452 fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
1453 let parse_result = empty_dep_index();
1454 let dep_index = DependencyIndex::build(parse_result.as_ref());
1455 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1456 let mut severities = HashMap::new();
1457 severities.insert(
1458 (
1459 deps_core::test_util::vuln_key("dep-0"),
1460 "RUSTSEC-2024-0001".to_string(),
1461 ),
1462 VulnSeverity::Critical,
1463 );
1464
1465 let finding = to_finding(
1466 EcosystemId::Cargo,
1467 Path::new("Cargo.toml"),
1468 &dep_index,
1469 &STUB_FORMATTER,
1470 diagnostic,
1471 &severities,
1472 &VulnKeys::default(),
1473 &HashMap::new(),
1474 );
1475 assert!(finding.advisory_severity.is_none());
1476 }
1477
1478 #[test]
1479 fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
1480 use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1481 use std::sync::Arc;
1482
1483 let advisory = Advisory::new(
1484 "RUSTSEC-2024-0001".to_string(),
1485 "2024-01-01T00:00:00Z".to_string(),
1486 VulnSeverity::High,
1487 )
1488 .expect("valid osv id");
1489 let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
1490 let mut map: VulnerabilityMap = HashMap::new();
1491 map.insert(
1492 deps_core::test_util::vuln_key("serde"),
1493 ScanOutcome::Vulnerable(dv),
1494 );
1495
1496 let index = advisory_severity_index(Some(&map));
1497 assert_eq!(
1498 index.get(&(
1499 deps_core::test_util::vuln_key("serde"),
1500 "RUSTSEC-2024-0001".to_string()
1501 )),
1502 Some(&VulnSeverity::High)
1503 );
1504 }
1505
1506 #[test]
1510 fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
1511 use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1512 use std::sync::Arc;
1513
1514 let advisory_for = |severity: VulnSeverity| {
1515 Arc::new(
1516 Advisory::new(
1517 "GHSA-shared-id".to_string(),
1518 "2024-01-01T00:00:00Z".to_string(),
1519 severity,
1520 )
1521 .expect("valid osv id"),
1522 )
1523 };
1524 let mut map: VulnerabilityMap = HashMap::new();
1525 map.insert(
1526 deps_core::test_util::vuln_key("package-a"),
1527 ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1528 vec![advisory_for(VulnSeverity::Critical)],
1529 1,
1530 ))),
1531 );
1532 map.insert(
1533 deps_core::test_util::vuln_key("package-b"),
1534 ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1535 vec![advisory_for(VulnSeverity::Low)],
1536 1,
1537 ))),
1538 );
1539
1540 let index = advisory_severity_index(Some(&map));
1541 assert_eq!(
1542 index.get(&(
1543 deps_core::test_util::vuln_key("package-a"),
1544 "GHSA-shared-id".to_string()
1545 )),
1546 Some(&VulnSeverity::Critical)
1547 );
1548 assert_eq!(
1549 index.get(&(
1550 deps_core::test_util::vuln_key("package-b"),
1551 "GHSA-shared-id".to_string()
1552 )),
1553 Some(&VulnSeverity::Low)
1554 );
1555 }
1556
1557 #[test]
1558 fn test_advisory_severity_index_empty_without_vulnerabilities() {
1559 assert!(advisory_severity_index(None).is_empty());
1560 }
1561
1562 #[test]
1571 fn test_check_pipeline_surfaces_gossip_cooldown_wording_via_version_data() {
1572 use crate::analyze::ManifestAnalysis;
1573 use deps_core::position::{Position, Range as PosRange};
1574 use deps_core::test_util::stub_gossip_findings;
1575 use deps_core::{Dependency, GossipCooldown, GossipRiskLevel, PublishTime};
1576 use std::any::Any;
1577 use std::collections::HashSet;
1578
1579 struct FixtureDep {
1580 name: PackageName,
1581 version_req: deps_core::VersionReq,
1582 version_range: PosRange,
1583 }
1584 impl Dependency for FixtureDep {
1585 fn name(&self) -> &PackageName {
1586 &self.name
1587 }
1588 fn name_range(&self) -> PosRange {
1589 PosRange::new(Position::new(0, 0), Position::new(0, 5))
1590 }
1591 fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
1592 Some(&self.version_req)
1593 }
1594 fn version_range(&self) -> Option<PosRange> {
1595 Some(self.version_range)
1596 }
1597 fn source(&self) -> deps_core::parser::DependencySource {
1598 deps_core::parser::DependencySource::Registry
1599 }
1600 fn as_any(&self) -> &dyn Any {
1601 self
1602 }
1603 }
1604
1605 struct FixtureParseResult {
1606 dep: FixtureDep,
1607 uri: url::Url,
1608 }
1609 impl deps_core::ParseResult for FixtureParseResult {
1610 fn dependencies(&self) -> Vec<&dyn Dependency> {
1611 vec![&self.dep]
1612 }
1613 fn workspace_root(&self) -> Option<&Path> {
1614 None
1615 }
1616 fn uri(&self) -> &url::Url {
1617 &self.uri
1618 }
1619 fn as_any(&self) -> &dyn Any {
1620 self
1621 }
1622 }
1623
1624 let uri: url::Url = "file:///project/Cargo.toml".parse().expect("valid URI");
1625 let parse_result: Box<dyn deps_core::ParseResult> = Box::new(FixtureParseResult {
1626 dep: FixtureDep {
1627 name: PackageName::new("serde"),
1628 version_req: deps_core::VersionReq::new("1.0"),
1629 version_range: PosRange::new(Position::new(0, 10), Position::new(0, 20)),
1630 },
1631 uri: uri.clone(),
1632 });
1633
1634 let mut cached_versions = HashMap::new();
1635 cached_versions.insert(
1636 PackageName::new("serde"),
1637 deps_core::lsp_helpers::PackageVersions::latest_only("2.0.0"),
1638 );
1639
1640 let mut gossip_findings = HashMap::new();
1641 gossip_findings.insert(
1642 PackageName::new("serde"),
1643 stub_gossip_findings(
1644 "2.0.0",
1645 Some(GossipCooldown::new(
1646 PublishTime::from_unix_secs(PublishTime::now().as_unix_secs() + 1_000),
1647 GossipRiskLevel::High,
1648 )),
1649 ),
1650 );
1651
1652 let analysis = ManifestAnalysis {
1653 parse_result,
1654 uri: uri.clone(),
1655 now: PublishTime::now(),
1656 ecosystem_id: EcosystemId::Cargo,
1657 cached_versions: cached_versions.clone(),
1658 resolved_versions: HashMap::new(),
1659 resolved_version_candidates: HashMap::new(),
1660 outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
1661 vulnerabilities: None,
1662 latest_status: None,
1663 fallback_status: None,
1664 cooldown_fallback_view: None,
1665 gossip_findings,
1666 licenses: HashMap::new(),
1667 license_policy: deps_core::licenses::LicensePolicy::default(),
1668 license_source: deps_core::LicenseSource::default(),
1669 network: deps_core::NetworkMode::Online,
1670 fetch_failed: HashSet::new(),
1671 registry_unreachable: false,
1672 license_fetch_incomplete: false,
1673 };
1674
1675 let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
1676 analysis.parse_result.as_ref(),
1677 analysis.version_data(),
1678 &STUB_FORMATTER,
1679 &uri,
1680 deps_core::FreshnessSettings::default(),
1681 deps_core::lsp_helpers::DiagnosticSeverities::default(),
1682 PublishTime::now(),
1683 );
1684
1685 assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
1686 assert!(
1687 diagnostics[0].message().contains("deps.dev/GOSSIP"),
1688 "check's diagnostic generation must consult live GOSSIP prefetch data: {}",
1689 diagnostics[0].message()
1690 );
1691
1692 let dep_index = DependencyIndex::build(analysis.parse_result.as_ref());
1693 let finding = to_finding(
1694 analysis.ecosystem_id,
1695 Path::new("Cargo.toml"),
1696 &dep_index,
1697 &STUB_FORMATTER,
1698 diagnostics.into_iter().next().expect("one diagnostic"),
1699 &HashMap::new(),
1700 &VulnKeys::default(),
1701 &cached_versions,
1702 );
1703 assert_eq!(finding.category, Category::Outdated);
1704 assert!(
1705 finding.message.contains("deps.dev/GOSSIP"),
1706 "the GOSSIP wording must survive into the SARIF finding: {}",
1707 finding.message
1708 );
1709 }
1710}