1use deps_core::diagnostic::{Diagnostic, Severity};
14use deps_core::lsp_helpers::{
15 DEPRECATED_DIAGNOSTIC_CODE, DependencyOutcomes, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
16 UNSATISFIABLE_DIAGNOSTIC_CODE, redact_name_for_diagnostic,
17};
18use deps_core::osv::{OsvClient, ScanOutcome, VulnSeverity, VulnerabilityMap};
19use deps_core::policy_config::PolicyConfig;
20use deps_core::position::Range;
21use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache, PackageName, VersionData};
22use deps_engine::classify::diff::{
23 merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
24};
25use deps_engine::classify::fetch::{
26 apply_fetch_outcomes, composer_minimum_stability, dedup_dependencies_by_source,
27 fetch_latest_versions_parallel,
28};
29use deps_engine::classify::license::prefetch_tier3_licenses;
30use deps_engine::classify::osv::build_scan_targets;
31use deps_engine::classify::resolved::{collect_in_use_versions, load_resolved_versions};
32use std::collections::{BTreeMap, HashMap};
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35use std::time::Duration;
36
37const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
49const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
51const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
56
57const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
83pub enum Category {
84 Outdated,
86 Yanked,
88 Vulnerable,
90 Unsatisfiable,
92 #[value(name = "mutable-ref")]
95 MutableRefPin,
96 License,
98 Deprecated,
100 #[value(skip)]
102 Other,
103}
104
105impl Category {
106 #[must_use]
108 pub const fn as_str(self) -> &'static str {
109 match self {
110 Self::Outdated => "outdated",
111 Self::Yanked => "yanked",
112 Self::Vulnerable => "vulnerable",
113 Self::Unsatisfiable => "unsatisfiable",
114 Self::MutableRefPin => "mutable-ref",
115 Self::License => "license",
116 Self::Deprecated => "deprecated",
117 Self::Other => "other",
118 }
119 }
120
121 #[must_use]
143 pub const fn description(self) -> &'static str {
144 match self {
145 Self::Outdated => {
146 "A newer version is published for the dependency's declared requirement."
147 }
148 Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
149 Self::Vulnerable => "A known security advisory affects the in-use version.",
150 Self::Unsatisfiable => "No published version satisfies the declared requirement.",
151 Self::MutableRefPin => {
152 "Pinned to a mutable ref (tag or branch) instead of a commit SHA."
153 }
154 Self::License => "The resolved license violates the configured allow/deny policy.",
155 Self::Deprecated => {
156 "The registry reports the package itself as deprecated or abandoned."
157 }
158 Self::Other => "A finding that does not map to any of the other categories.",
159 }
160 }
161}
162
163impl std::fmt::Display for Category {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 f.write_str(self.as_str())
166 }
167}
168
169#[derive(Debug, Clone)]
171pub struct CheckFinding {
172 pub ecosystem: EcosystemId,
174 pub manifest_path: PathBuf,
176 pub dependency_name: Option<String>,
182 pub requirement: Option<String>,
184 pub category: Category,
186 pub code: Option<String>,
194 pub advisory_url: Option<String>,
203 pub advisory_severity: Option<VulnSeverity>,
211 pub severity: Severity,
213 pub range: Range,
215 pub message: String,
217}
218
219#[derive(Debug, Clone, Default)]
221pub struct CheckReport {
222 pub findings: Vec<CheckFinding>,
224}
225
226impl CheckReport {
227 #[must_use]
257 pub fn summary(&self) -> BTreeMap<Category, usize> {
258 let mut counts = BTreeMap::new();
259 for finding in &self.findings {
260 *counts.entry(finding.category).or_insert(0_usize) += 1;
261 }
262 counts
263 }
264}
265
266#[derive(Debug, Clone)]
269pub struct FailOnPolicy {
270 categories: Vec<Category>,
271}
272
273impl FailOnPolicy {
274 #[must_use]
276 pub const fn new(categories: Vec<Category>) -> Self {
277 Self { categories }
278 }
279
280 #[must_use]
293 pub fn default_categories() -> Self {
294 Self::new(vec![
295 Category::Vulnerable,
296 Category::Yanked,
297 Category::Unsatisfiable,
298 ])
299 }
300
301 #[must_use]
303 pub fn categories(&self) -> &[Category] {
304 &self.categories
305 }
306
307 #[must_use]
309 pub fn matches(&self, findings: &[CheckFinding]) -> bool {
310 findings
311 .iter()
312 .any(|finding| self.categories.contains(&finding.category))
313 }
314}
315
316impl Default for FailOnPolicy {
317 fn default() -> Self {
318 Self::default_categories()
319 }
320}
321
322#[derive(Clone)]
327pub struct CheckContext {
328 pub cache: Arc<HttpCache>,
330 pub osv: Arc<OsvClient>,
332 pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
334 pub policy: PolicyConfig,
336}
337
338#[derive(Debug, thiserror::Error)]
340pub enum CheckError {
341 #[error("failed to parse {path}: {source}")]
343 Parse {
344 path: PathBuf,
346 #[source]
348 source: deps_core::DepsError,
349 },
350 #[error("could not build a file URI for {path}")]
353 InvalidPath {
354 path: PathBuf,
356 },
357}
358
359#[derive(Debug, Clone)]
361pub struct ManifestCheckResult {
362 pub findings: Vec<CheckFinding>,
364 pub registry_unreachable: bool,
375 pub license_fetch_incomplete: bool,
383}
384
385pub async fn check_manifest(
402 ecosystem: &Arc<dyn Ecosystem>,
403 manifest_path: &Path,
404 display_path: &Path,
405 content: &str,
406 ctx: &CheckContext,
407) -> Result<ManifestCheckResult, CheckError> {
408 let uri = path_to_uri(manifest_path).ok_or_else(|| CheckError::InvalidPath {
409 path: manifest_path.to_path_buf(),
410 })?;
411 let parse_result = deps_core::parse_manifest_blocking(ecosystem, content, &uri)
412 .await
413 .map_err(|source| CheckError::Parse {
414 path: manifest_path.to_path_buf(),
415 source,
416 })?;
417 let formatter = ecosystem.formatter();
418 let ecosystem_id = ecosystem.ecosystem_id();
419
420 let (resolved_versions, resolved_version_candidates) =
421 load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref()).await;
422
423 let (dep_sources, collided_names) =
424 dedup_dependencies_by_source(parse_result.as_ref(), formatter);
425 let in_use = collect_in_use_versions(
426 parse_result.as_ref(),
427 &resolved_versions,
428 &resolved_version_candidates,
429 formatter,
430 ecosystem_id,
431 );
432 let minimum_stability = composer_minimum_stability(parse_result.as_ref());
433 let attempted_names: Vec<PackageName> = dep_sources.keys().cloned().collect();
434
435 let fetch_result = fetch_latest_versions_parallel(
436 ecosystem.registry(),
437 dep_sources.into_iter().collect(),
438 &in_use,
439 None,
440 ctx.policy.freshness.to_settings(),
441 ctx.policy.cache.fetch_timeout_secs,
442 ctx.policy.cache.max_concurrent_fetches,
443 minimum_stability.as_deref(),
444 )
445 .await;
446 let registry_unreachable = !ctx.policy.network.offline && !fetch_result.fetch_failed.is_empty();
449
450 let mut outcomes = DependencyOutcomes::new();
451 let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
452 apply_fetch_outcomes(
453 &mut outcomes,
454 fetch_result.yanked_versions,
455 fetch_result.fetch_failed,
456 collided_names,
457 formatter,
458 );
459 merge_deprecations_after_fetch(
460 &mut outcomes,
461 &fetched_names,
462 fetch_result.deprecations,
463 formatter,
464 );
465 merge_no_comparable_versions_after_fetch(
466 &mut outcomes,
467 &attempted_names,
468 fetch_result.no_comparable_versions,
469 formatter,
470 );
471 let cached_versions = fetch_result.versions;
472 let mut licenses = fetch_result.licenses;
475
476 let license_policy = ctx.policy.license_policy.to_policy();
479
480 let run_tier3_prefetch = !ctx.policy.network.offline;
494 let tier3_license_fetch = async {
495 if run_tier3_prefetch {
496 prefetch_tier3_licenses(
497 ecosystem.as_ref(),
498 parse_result.as_ref(),
499 &resolved_versions,
500 &resolved_version_candidates,
501 &license_policy,
502 ctx.policy.cache.fetch_timeout_secs,
503 ctx.policy.cache.max_concurrent_fetches,
504 )
505 .await
506 } else {
507 deps_engine::classify::license::TierThreeLicenseFetch::default()
508 }
509 };
510
511 let run_osv_scan =
512 ctx.policy.diagnostics.vulnerabilities_enabled && !ctx.policy.network.offline;
513 let osv_scan = async {
514 if run_osv_scan {
515 let (targets, skipped) = build_scan_targets(
516 parse_result.as_ref(),
517 &resolved_versions,
518 &resolved_version_candidates,
519 formatter,
520 ecosystem_id,
521 );
522 let mut vulns = skipped;
523 if !targets.is_empty() {
524 let timeout = Duration::from_secs(
525 ctx.policy
526 .cache
527 .fetch_timeout_secs
528 .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
529 );
530 let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
531 vulns.extend(scanned);
532 }
533 Some(vulns)
534 } else {
535 None
536 }
537 };
538
539 let (tier3_result, vulnerabilities): (_, Option<VulnerabilityMap>) =
540 tokio::join!(tier3_license_fetch, osv_scan);
541
542 for (name, license) in tier3_result.licenses {
549 licenses.entry(name).or_insert(license);
550 }
551 let license_fetch_incomplete = tier3_result.timed_out > 0;
562
563 let mut version_data = VersionData::new(&cached_versions, &resolved_versions)
564 .with_resolved_version_candidates(&resolved_version_candidates)
565 .with_outcomes(&outcomes)
566 .with_ecosystem(ecosystem_id)
567 .with_offline(ctx.policy.network.offline)
568 .with_license_source(ecosystem.license_source())
569 .with_license_policy(&license_policy)
570 .with_license_prefetch(&licenses);
571 if let Some(vulnerabilities) = vulnerabilities.as_ref() {
572 version_data = version_data.with_vulnerabilities(vulnerabilities);
573 }
574
575 let severities = ctx.policy.diagnostics.to_severities();
576 let diagnostics = ecosystem
577 .generate_diagnostics(
578 parse_result.as_ref(),
579 version_data,
580 &uri,
581 ctx.policy.freshness.to_settings(),
582 severities,
583 )
584 .await;
585
586 let dep_index = DependencyIndex::build(parse_result.as_ref());
587 let advisory_severities = advisory_severity_index(vulnerabilities.as_ref());
588 let vuln_keys = deps_core::osv::vulnerability_keys(
591 parse_result.as_ref(),
592 &resolved_versions,
593 Some(&resolved_version_candidates),
594 formatter,
595 ecosystem_id,
596 );
597 let findings = diagnostics
598 .into_iter()
599 .map(|diagnostic| {
600 to_finding(
601 ecosystem_id,
602 display_path,
603 &dep_index,
604 formatter,
605 diagnostic,
606 &advisory_severities,
607 &vuln_keys,
608 )
609 })
610 .collect();
611 Ok(ManifestCheckResult {
612 findings,
613 registry_unreachable,
614 license_fetch_incomplete,
615 })
616}
617
618struct DependencyIndex<'a> {
628 by_name_range: HashMap<Range, &'a dyn Dependency>,
629 by_version_range: HashMap<Range, &'a dyn Dependency>,
630}
631
632impl<'a> DependencyIndex<'a> {
633 fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
634 let mut by_name_range = HashMap::new();
635 let mut by_version_range = HashMap::new();
636 for dep in parse_result.dependencies() {
637 if !dep.name_range_is_synthetic() {
638 by_name_range.insert(dep.name_range(), dep);
639 }
640 if let Some(version_range) = dep.version_range() {
641 by_version_range.insert(version_range, dep);
642 }
643 }
644 Self {
645 by_name_range,
646 by_version_range,
647 }
648 }
649
650 fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
654 self.by_name_range
655 .get(&range)
656 .or_else(|| self.by_version_range.get(&range))
657 .copied()
658 }
659}
660
661fn advisory_severity_index(
679 vulnerabilities: Option<&VulnerabilityMap>,
680) -> HashMap<(String, String), VulnSeverity> {
681 let mut index = HashMap::new();
682 let Some(vulnerabilities) = vulnerabilities else {
683 return index;
684 };
685 for (dependency_key, outcome) in vulnerabilities {
686 if let ScanOutcome::Vulnerable(dv) = outcome {
687 for advisory in dv.advisories.items() {
688 index.insert(
689 (dependency_key.clone(), advisory.id.clone()),
690 advisory.severity,
691 );
692 }
693 }
694 }
695 index
696}
697
698fn to_finding(
704 ecosystem: EcosystemId,
705 display_path: &Path,
706 dep_index: &DependencyIndex<'_>,
707 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
708 diagnostic: Diagnostic,
709 advisory_severities: &HashMap<(String, String), VulnSeverity>,
710 vuln_keys: &HashMap<Range, String>,
711) -> CheckFinding {
712 let category = classify(&diagnostic, formatter);
713 let dep = dep_index.lookup(diagnostic.range);
714 let code = diagnostic.code().map(str::to_string);
715 let advisory_url = diagnostic
716 .code_description
717 .as_ref()
718 .map(|code_description| code_description.href.as_str().to_string());
719 let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
720 let dependency_key = vuln_keys.get(&dep.name_range())?;
721 advisory_severities
722 .get(&(dependency_key.clone(), code.to_string()))
723 .copied()
724 });
725 CheckFinding {
726 ecosystem,
727 manifest_path: display_path.to_path_buf(),
728 dependency_name: dep.map(|d| redact_name_for_diagnostic(d.name())),
729 requirement: dep
730 .and_then(Dependency::version_requirement)
731 .map(ToString::to_string),
732 category,
733 code,
734 advisory_url,
735 advisory_severity,
736 severity: diagnostic.severity.unwrap_or(Severity::Warning),
737 range: diagnostic.range,
738 message: diagnostic.message().to_string(),
739 }
740}
741
742fn classify(
759 diagnostic: &Diagnostic,
760 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
761) -> Category {
762 if let Some(code) = diagnostic.code() {
763 return match code {
764 UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
765 LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
766 DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
767 GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
768 Category::MutableRefPin
769 }
770 GITLAB_CI_UNRESOLVED_HOST_CODE => Category::Other,
771 _ => Category::Vulnerable,
772 };
773 }
774 if diagnostic.message().starts_with("Newer version available") {
775 return Category::Outdated;
776 }
777 if diagnostic.message().contains(formatter.yanked_message()) {
778 return Category::Yanked;
779 }
780 if diagnostic.message().ends_with("more advisories") {
783 return Category::Vulnerable;
784 }
785 Category::Other
786}
787
788fn path_to_uri(path: &Path) -> Option<url::Url> {
796 let absolute = if path.is_absolute() {
797 path.to_path_buf()
798 } else {
799 std::env::current_dir()
800 .map(|cwd| cwd.join(path))
801 .unwrap_or_else(|_| path.to_path_buf())
802 };
803 url::Url::from_file_path(&absolute).ok()
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 fn finding(category: Category) -> CheckFinding {
811 CheckFinding {
812 ecosystem: EcosystemId::Cargo,
813 manifest_path: PathBuf::from("Cargo.toml"),
814 dependency_name: Some("serde".to_string()),
815 requirement: Some("1.0".to_string()),
816 category,
817 code: None,
818 advisory_url: None,
819 advisory_severity: None,
820 severity: Severity::Warning,
821 range: Range::default(),
822 message: "test".to_string(),
823 }
824 }
825
826 #[test]
827 fn test_category_as_str_matches_fr009_tokens() {
828 assert_eq!(Category::Outdated.as_str(), "outdated");
829 assert_eq!(Category::Yanked.as_str(), "yanked");
830 assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
831 assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
832 assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
833 assert_eq!(Category::License.as_str(), "license");
834 assert_eq!(Category::Deprecated.as_str(), "deprecated");
835 assert_eq!(Category::Other.as_str(), "other");
836 }
837
838 #[test]
839 fn test_fail_on_policy_default_categories() {
840 let policy = FailOnPolicy::default_categories();
841 assert!(policy.matches(&[finding(Category::Vulnerable)]));
842 assert!(policy.matches(&[finding(Category::Yanked)]));
843 assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
844 assert!(!policy.matches(&[finding(Category::Outdated)]));
845 assert!(!policy.matches(&[finding(Category::License)]));
846 assert!(!policy.matches(&[finding(Category::Deprecated)]));
847 assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
848 assert!(!policy.matches(&[finding(Category::Other)]));
849 }
850
851 #[test]
852 fn test_fail_on_policy_custom_categories() {
853 let policy = FailOnPolicy::new(vec![Category::License]);
854 assert!(policy.matches(&[finding(Category::License)]));
855 assert!(!policy.matches(&[finding(Category::Vulnerable)]));
856 }
857
858 #[test]
859 fn test_fail_on_policy_empty_findings_never_matches() {
860 assert!(!FailOnPolicy::default_categories().matches(&[]));
861 }
862
863 #[test]
864 fn test_check_report_summary_counts_per_category() {
865 let report = CheckReport {
866 findings: vec![
867 finding(Category::Outdated),
868 finding(Category::Outdated),
869 finding(Category::Vulnerable),
870 ],
871 };
872 let summary = report.summary();
873 assert_eq!(summary.get(&Category::Outdated), Some(&2));
874 assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
875 assert_eq!(summary.get(&Category::License), None);
876 }
877
878 #[test]
879 fn test_check_report_summary_empty_for_no_findings() {
880 let report = CheckReport::default();
881 assert!(report.summary().is_empty());
882 }
883
884 struct StubFormatter;
885 impl deps_core::lsp_helpers::PackageNaming for StubFormatter {}
886 impl deps_core::lsp_helpers::PackageRendering for StubFormatter {
887 fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
888 version.to_string()
889 }
890 fn package_url(&self, name: &PackageName) -> String {
891 name.as_str().to_string()
892 }
893 }
894 impl deps_core::lsp_helpers::RequirementResolution for StubFormatter {}
895 impl deps_core::lsp_helpers::DiagnosticMessages for StubFormatter {}
896 impl deps_core::lsp_helpers::DiagnosticPolicy for StubFormatter {}
897 impl deps_core::lsp_helpers::SourcePolicy for StubFormatter {}
898 impl deps_core::lsp_helpers::OsvNaming for StubFormatter {}
899
900 fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
901 let diagnostic =
902 Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
903 match code {
904 Some(code) => diagnostic.with_code(code),
905 None => diagnostic,
906 }
907 }
908
909 #[test]
910 fn test_classify_unsatisfiable_by_code() {
911 let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
912 assert_eq!(classify(&d, &StubFormatter), Category::Unsatisfiable);
913 }
914
915 #[test]
916 fn test_classify_license_by_code() {
917 let d = diagnostic_with(
918 Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
919 "GPL-3.0 denied",
920 );
921 assert_eq!(classify(&d, &StubFormatter), Category::License);
922 }
923
924 #[test]
925 fn test_classify_deprecated_by_code() {
926 let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
927 assert_eq!(classify(&d, &StubFormatter), Category::Deprecated);
928 }
929
930 #[test]
931 fn test_classify_mutable_ref_pin_by_code() {
932 let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
933 assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
934 let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
935 assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
936 }
937
938 #[test]
939 fn test_classify_advisory_code_is_vulnerable() {
940 let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
941 assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
942 }
943
944 #[test]
945 fn test_classify_outdated_by_message_prefix() {
946 let d = diagnostic_with(None, "Newer version available: 2.0.0");
947 assert_eq!(classify(&d, &StubFormatter), Category::Outdated);
948 }
949
950 #[test]
951 fn test_classify_yanked_by_formatter_message() {
952 let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
953 assert_eq!(classify(&d, &StubFormatter), Category::Yanked);
954 }
955
956 #[test]
957 fn test_classify_unknown_package_is_other() {
958 let d = diagnostic_with(None, "Unknown package 'left-pad'");
959 assert_eq!(classify(&d, &StubFormatter), Category::Other);
960 }
961
962 #[test]
965 fn test_classify_advisory_overflow_summary_is_vulnerable() {
966 let d = diagnostic_with(None, "+5 more advisories");
967 assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
968 }
969
970 #[test]
974 fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
975 let d = diagnostic_with(
976 Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
977 "registries.gitlab_instance_host is unset; skipping component/project host resolution",
978 );
979 assert_eq!(classify(&d, &StubFormatter), Category::Other);
980 }
981
982 fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
989 deps_core::test_util::stub_parse_result_with_dependencies(1)
990 }
991
992 fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
993 deps_core::test_util::stub_parse_result_with_dependencies(0)
994 }
995
996 struct NamedFixtureDep {
1001 name: PackageName,
1002 }
1003
1004 impl deps_core::Dependency for NamedFixtureDep {
1005 fn name(&self) -> &PackageName {
1006 &self.name
1007 }
1008 fn name_range(&self) -> Range {
1009 Range::default()
1010 }
1011 fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
1012 None
1013 }
1014 fn version_range(&self) -> Option<Range> {
1015 None
1016 }
1017 fn source(&self) -> deps_core::parser::DependencySource {
1018 deps_core::parser::DependencySource::Registry
1019 }
1020 fn as_any(&self) -> &dyn std::any::Any {
1021 self
1022 }
1023 }
1024
1025 struct NamedFixtureParseResult {
1026 dep: NamedFixtureDep,
1027 uri: url::Url,
1028 }
1029
1030 impl deps_core::ParseResult for NamedFixtureParseResult {
1031 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
1032 vec![&self.dep]
1033 }
1034 fn workspace_root(&self) -> Option<&Path> {
1035 None
1036 }
1037 fn uri(&self) -> &url::Url {
1038 &self.uri
1039 }
1040 fn as_any(&self) -> &dyn std::any::Any {
1041 self
1042 }
1043 }
1044
1045 fn dep_index_with_named_dependency(name: &str) -> Box<dyn deps_core::ParseResult> {
1046 Box::new(NamedFixtureParseResult {
1047 dep: NamedFixtureDep {
1048 name: PackageName::new(name),
1049 },
1050 uri: "file:///project/manifest.toml".parse().expect("valid URI"),
1051 })
1052 }
1053
1054 #[test]
1058 fn test_to_finding_extracts_string_code_from_diagnostic() {
1059 let parse_result = empty_dep_index();
1060 let dep_index = DependencyIndex::build(parse_result.as_ref());
1061 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1062 let finding = to_finding(
1063 EcosystemId::Cargo,
1064 Path::new("Cargo.toml"),
1065 &dep_index,
1066 &StubFormatter,
1067 diagnostic,
1068 &HashMap::new(),
1069 &HashMap::new(),
1070 );
1071 assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
1072 }
1073
1074 #[test]
1075 fn test_to_finding_code_is_none_without_a_diagnostic_code() {
1076 let parse_result = empty_dep_index();
1077 let dep_index = DependencyIndex::build(parse_result.as_ref());
1078 let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
1079 let finding = to_finding(
1080 EcosystemId::Cargo,
1081 Path::new("Cargo.toml"),
1082 &dep_index,
1083 &StubFormatter,
1084 diagnostic,
1085 &HashMap::new(),
1086 &HashMap::new(),
1087 );
1088 assert!(finding.code.is_none());
1089 }
1090
1091 #[test]
1092 fn test_to_finding_extracts_advisory_url_from_code_description() {
1093 let parse_result = empty_dep_index();
1094 let dep_index = DependencyIndex::build(parse_result.as_ref());
1095 let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
1096 .parse()
1097 .expect("valid URL");
1098 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
1099 .with_code_description(deps_core::diagnostic::CodeDescription::new(href));
1100 let finding = to_finding(
1101 EcosystemId::Cargo,
1102 Path::new("Cargo.toml"),
1103 &dep_index,
1104 &StubFormatter,
1105 diagnostic,
1106 &HashMap::new(),
1107 &HashMap::new(),
1108 );
1109 assert_eq!(
1110 finding.advisory_url.as_deref(),
1111 Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
1112 );
1113 }
1114
1115 #[test]
1116 fn test_to_finding_advisory_url_is_none_without_code_description() {
1117 let parse_result = empty_dep_index();
1118 let dep_index = DependencyIndex::build(parse_result.as_ref());
1119 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1120 let finding = to_finding(
1121 EcosystemId::Cargo,
1122 Path::new("Cargo.toml"),
1123 &dep_index,
1124 &StubFormatter,
1125 diagnostic,
1126 &HashMap::new(),
1127 &HashMap::new(),
1128 );
1129 assert!(finding.advisory_url.is_none());
1130 }
1131
1132 #[test]
1136 fn test_to_finding_redacts_credential_shaped_dependency_name() {
1137 let parse_result = dep_index_with_named_dependency(
1138 "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1139 );
1140 let dep_index = DependencyIndex::build(parse_result.as_ref());
1141 let diagnostic = diagnostic_with(None, "Unknown package");
1142
1143 let finding = to_finding(
1144 EcosystemId::Cargo,
1145 Path::new("Cargo.toml"),
1146 &dep_index,
1147 &StubFormatter,
1148 diagnostic,
1149 &HashMap::new(),
1150 &HashMap::new(),
1151 );
1152
1153 let name = finding
1154 .dependency_name
1155 .expect("range matched the dependency");
1156 assert!(name.contains("***@"));
1157 assert!(!name.contains("glpat-AAAABBBBCCCCDDDD"));
1158 }
1159
1160 #[test]
1168 fn test_to_sarif_fingerprint_never_carries_a_credential_through_to_finding() {
1169 let parse_result = dep_index_with_named_dependency(
1170 "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p",
1171 );
1172 let dep_index = DependencyIndex::build(parse_result.as_ref());
1173 let diagnostic = diagnostic_with(None, "Unknown package");
1174
1175 let finding = to_finding(
1176 EcosystemId::Cargo,
1177 Path::new("Cargo.toml"),
1178 &dep_index,
1179 &StubFormatter,
1180 diagnostic,
1181 &HashMap::new(),
1182 &HashMap::new(),
1183 );
1184
1185 let sarif = crate::format::sarif::to_sarif(&CheckReport {
1186 findings: vec![finding],
1187 });
1188 let fp = sarif.runs[0].results.as_ref().expect("one result")[0]
1189 .partial_fingerprints
1190 .as_ref()
1191 .expect("fingerprint set")
1192 .get("depsCli/v1")
1193 .expect("depsCli/v1 fingerprint key")
1194 .clone();
1195
1196 assert!(
1197 !fp.contains("glpat-AAAABBBBCCCCDDDD"),
1198 "token leaked (raw or percent-encoded, since `-` is never percent-escaped): {fp}"
1199 );
1200 let decoded = urlencoding::decode(&fp).expect("fingerprint is valid percent-encoding");
1201 assert!(
1202 decoded.contains("***@gitlab.corp"),
1203 "expected the redacted '***@host' marker, got: {decoded}"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_to_finding_resolves_advisory_severity_from_index() {
1209 let parse_result = dep_index_with_one_dependency();
1210 let dep_index = DependencyIndex::build(parse_result.as_ref());
1211 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1212
1213 let mut vuln_keys = HashMap::new();
1214 vuln_keys.insert(Range::default(), "dep-0".to_string());
1215 let mut severities = HashMap::new();
1216 severities.insert(
1217 ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1218 VulnSeverity::Critical,
1219 );
1220
1221 let finding = to_finding(
1222 EcosystemId::Cargo,
1223 Path::new("Cargo.toml"),
1224 &dep_index,
1225 &StubFormatter,
1226 diagnostic,
1227 &severities,
1228 &vuln_keys,
1229 );
1230 assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
1231 }
1232
1233 #[test]
1234 fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
1235 let parse_result = dep_index_with_one_dependency();
1236 let dep_index = DependencyIndex::build(parse_result.as_ref());
1237 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1238 let mut vuln_keys = HashMap::new();
1239 vuln_keys.insert(Range::default(), "dep-0".to_string());
1240
1241 let finding = to_finding(
1242 EcosystemId::Cargo,
1243 Path::new("Cargo.toml"),
1244 &dep_index,
1245 &StubFormatter,
1246 diagnostic,
1247 &HashMap::new(),
1248 &vuln_keys,
1249 );
1250 assert!(finding.advisory_severity.is_none());
1251 }
1252
1253 #[test]
1257 fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
1258 let parse_result = empty_dep_index();
1259 let dep_index = DependencyIndex::build(parse_result.as_ref());
1260 let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1261 let mut severities = HashMap::new();
1262 severities.insert(
1263 ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1264 VulnSeverity::Critical,
1265 );
1266
1267 let finding = to_finding(
1268 EcosystemId::Cargo,
1269 Path::new("Cargo.toml"),
1270 &dep_index,
1271 &StubFormatter,
1272 diagnostic,
1273 &severities,
1274 &HashMap::new(),
1275 );
1276 assert!(finding.advisory_severity.is_none());
1277 }
1278
1279 #[test]
1280 fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
1281 use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1282 use std::sync::Arc;
1283
1284 let advisory = Advisory::new(
1285 "RUSTSEC-2024-0001".to_string(),
1286 "2024-01-01T00:00:00Z".to_string(),
1287 VulnSeverity::High,
1288 "https://osv.dev/vulnerability/RUSTSEC-2024-0001".to_string(),
1289 );
1290 let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
1291 let mut map: VulnerabilityMap = HashMap::new();
1292 map.insert("serde".to_string(), ScanOutcome::Vulnerable(dv));
1293
1294 let index = advisory_severity_index(Some(&map));
1295 assert_eq!(
1296 index.get(&("serde".to_string(), "RUSTSEC-2024-0001".to_string())),
1297 Some(&VulnSeverity::High)
1298 );
1299 }
1300
1301 #[test]
1305 fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
1306 use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1307 use std::sync::Arc;
1308
1309 let advisory_for = |severity: VulnSeverity| {
1310 Arc::new(Advisory::new(
1311 "GHSA-shared-id".to_string(),
1312 "2024-01-01T00:00:00Z".to_string(),
1313 severity,
1314 "https://osv.dev/vulnerability/GHSA-shared-id".to_string(),
1315 ))
1316 };
1317 let mut map: VulnerabilityMap = HashMap::new();
1318 map.insert(
1319 "package-a".to_string(),
1320 ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1321 vec![advisory_for(VulnSeverity::Critical)],
1322 1,
1323 ))),
1324 );
1325 map.insert(
1326 "package-b".to_string(),
1327 ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1328 vec![advisory_for(VulnSeverity::Low)],
1329 1,
1330 ))),
1331 );
1332
1333 let index = advisory_severity_index(Some(&map));
1334 assert_eq!(
1335 index.get(&("package-a".to_string(), "GHSA-shared-id".to_string())),
1336 Some(&VulnSeverity::Critical)
1337 );
1338 assert_eq!(
1339 index.get(&("package-b".to_string(), "GHSA-shared-id".to_string())),
1340 Some(&VulnSeverity::Low)
1341 );
1342 }
1343
1344 #[test]
1345 fn test_advisory_severity_index_empty_without_vulnerabilities() {
1346 assert!(advisory_severity_index(None).is_empty());
1347 }
1348}