1use crate::report::{Category, CheckFinding, CheckReport};
49use deps_core::diagnostic::Severity;
50use deps_core::osv::{VulnSeverity, is_valid_osv_id, validated_osv_url};
51use deps_core::position::Range;
52use serde_sarif::sarif::{
53 ArtifactLocation, Location, MultiformatMessageString, PhysicalLocation, PropertyBag, Region,
54 ReportingDescriptor, Result as SarifResult, ResultLevel, Run, RunAutomationDetails, SCHEMA_URL,
55 Sarif, Tool, ToolComponent, Version,
56};
57use std::collections::{BTreeMap, HashMap};
58use std::path::{Component, Path};
59
60#[must_use]
73pub fn to_sarif(report: &CheckReport) -> Sarif {
74 let contexts = collect_result_contexts(&report.findings);
75 let rule_meta = collect_rule_meta(&report.findings, &contexts);
76 let rule_indices: HashMap<&str, usize> = rule_meta
77 .keys()
78 .enumerate()
79 .map(|(index, id)| (id.as_str(), index))
80 .collect();
81
82 let rules: Vec<ReportingDescriptor> = rule_meta
83 .iter()
84 .map(|(id, meta)| build_rule_descriptor(id, meta))
85 .collect();
86
87 let results: Vec<SarifResult> = report
88 .findings
89 .iter()
90 .zip(&contexts)
91 .map(|(finding, context)| {
92 let rule_index = rule_indices
93 .get(context.rule_id.as_str())
94 .copied()
95 .unwrap_or_default();
96 to_sarif_result(finding, rule_index, context)
97 })
98 .collect();
99
100 let driver = ToolComponent::builder()
101 .name("deps-cli")
102 .version(env!("CARGO_PKG_VERSION"))
103 .information_uri(env!("CARGO_PKG_REPOSITORY"))
104 .rules(rules)
105 .build();
106
107 let automation_details = RunAutomationDetails::builder().id(automation_id()).build();
108
109 let run = Run::builder()
110 .tool(Tool::from(driver))
111 .results(results)
112 .automation_details(automation_details)
113 .build();
114
115 Sarif::builder()
116 .version(Version::V2_1_0.to_string())
117 .schema(SCHEMA_URL)
118 .runs(vec![run])
119 .build()
120}
121
122fn is_advisory_finding(finding: &CheckFinding) -> bool {
138 finding.category == Category::Vulnerable && finding.code.as_deref().is_some_and(is_valid_osv_id)
139}
140
141fn sarif_rule_id(finding: &CheckFinding) -> &str {
144 if is_advisory_finding(finding) {
145 finding
147 .code
148 .as_deref()
149 .unwrap_or_else(|| finding.category.as_str())
150 } else {
151 finding.category.as_str()
152 }
153}
154
155struct RuleMeta<'a> {
159 category: Category,
160 is_advisory: bool,
163 message: &'a str,
164 advisory_url: Option<String>,
165 advisory_severity: Option<VulnSeverity>,
166}
167
168fn collect_rule_meta<'a>(
181 findings: &'a [CheckFinding],
182 contexts: &[ResultContext],
183) -> BTreeMap<String, RuleMeta<'a>> {
184 let mut rules = BTreeMap::new();
185 for (finding, context) in findings.iter().zip(contexts) {
186 rules
187 .entry(context.rule_id.clone())
188 .or_insert_with(|| RuleMeta {
189 category: finding.category,
190 is_advisory: is_advisory_finding(finding),
191 message: finding.message.as_str(),
192 advisory_url: finding.advisory_url.clone(),
193 advisory_severity: finding.advisory_severity,
194 });
195 }
196 rules
197}
198
199fn build_rule_descriptor(id: &str, meta: &RuleMeta<'_>) -> ReportingDescriptor {
207 let is_advisory = meta.is_advisory;
208
209 let short_description = MultiformatMessageString::builder()
210 .text(meta.category.description())
211 .build();
212
213 let (help_uri, full_description) = if is_advisory {
214 let help_uri = meta.advisory_url.clone().or_else(|| validated_osv_url(id));
215 let full_description = MultiformatMessageString::builder()
216 .text(meta.message.to_string())
217 .build();
218 (help_uri, Some(full_description))
219 } else {
220 (None, None)
221 };
222
223 let properties = if is_advisory {
224 meta.advisory_severity
225 .and_then(security_severity_score)
226 .map(|score| {
227 let mut additional_properties = BTreeMap::new();
228 additional_properties.insert(
229 "security-severity".to_string(),
230 serde_json::Value::String(score.to_string()),
231 );
232 PropertyBag::builder()
233 .additional_properties(additional_properties)
234 .build()
235 })
236 } else {
237 None
238 };
239
240 let name = if is_advisory {
243 id.to_string()
244 } else {
245 meta.category.as_str().to_string()
246 };
247
248 ReportingDescriptor {
249 default_configuration: None,
250 deprecated_guids: None,
251 deprecated_ids: None,
252 deprecated_names: None,
253 full_description,
254 guid: None,
255 help: None,
256 help_uri,
257 id: id.to_string(),
258 message_strings: None,
259 name: Some(name),
260 properties,
261 relationships: None,
262 short_description: Some(short_description),
263 }
264}
265
266fn security_severity_score(severity: VulnSeverity) -> Option<&'static str> {
278 match severity {
279 VulnSeverity::Malicious => Some("10.0"),
280 VulnSeverity::Critical => Some("9.5"),
281 VulnSeverity::High => Some("8.0"),
282 VulnSeverity::Medium => Some("5.5"),
283 VulnSeverity::Low => Some("2.0"),
284 VulnSeverity::Unknown | VulnSeverity::Informational => None,
285 _ => None,
287 }
288}
289
290fn automation_id() -> String {
306 automation_id_with_env(|name| std::env::var(name).ok())
307}
308
309fn automation_id_with_env(env: impl Fn(&str) -> Option<String>) -> String {
314 let Some(run_id) = env("GITHUB_RUN_ID") else {
315 return "deps-cli/local".to_string();
316 };
317 let workflow = env("GITHUB_WORKFLOW").unwrap_or_default();
318 let job = env("GITHUB_JOB").unwrap_or_default();
319 let run = match env("GITHUB_RUN_ATTEMPT") {
320 Some(attempt) => format!("{run_id}-{attempt}"),
321 None => run_id,
322 };
323 format!("deps-cli/{workflow}/{job}/{run}")
324}
325
326struct ResultContext {
331 manifest_uri: String,
332 rule_id: String,
333 fingerprint: String,
334}
335
336fn collect_result_contexts(findings: &[CheckFinding]) -> Vec<ResultContext> {
360 let mut seen: HashMap<(String, &str, &str), usize> = HashMap::new();
363 findings
364 .iter()
365 .map(|finding| {
366 let manifest = manifest_uri(&finding.manifest_path);
367 let dependency = finding.dependency_name.as_deref().unwrap_or("");
368 let rule_id = sarif_rule_id(finding);
369
370 let counter = seen
371 .entry((manifest.clone(), dependency, rule_id))
372 .or_insert(0);
373 let ordinal = *counter;
374 *counter += 1;
375
376 let fingerprint = format!(
377 "{manifest}|{}|{}|{ordinal}",
378 urlencoding::encode(dependency),
379 urlencoding::encode(rule_id),
380 );
381
382 ResultContext {
383 manifest_uri: manifest,
384 rule_id: rule_id.to_string(),
385 fingerprint,
386 }
387 })
388 .collect()
389}
390
391fn to_sarif_result(
395 finding: &CheckFinding,
396 rule_index: usize,
397 context: &ResultContext,
398) -> SarifResult {
399 let region = to_sarif_region(finding.range);
400 let artifact_location = ArtifactLocation::builder()
401 .uri(context.manifest_uri.as_str())
402 .build();
403 let physical_location = PhysicalLocation::builder()
404 .artifact_location(artifact_location)
405 .region(region)
406 .build();
407 let location = Location::builder()
408 .physical_location(physical_location)
409 .build();
410
411 let mut partial_fingerprints = BTreeMap::new();
412 partial_fingerprints.insert("depsCli/v1".to_string(), context.fingerprint.clone());
413
414 SarifResult::builder()
415 .rule_id(context.rule_id.as_str())
416 .rule_index(i64::try_from(rule_index).unwrap_or(i64::MAX))
417 .message(finding.message.as_str())
418 .locations(vec![location])
419 .level(to_result_level(finding.severity))
420 .partial_fingerprints(partial_fingerprints)
421 .build()
422}
423
424fn manifest_uri(path: &Path) -> String {
445 path.components()
446 .filter_map(|component| match component {
447 Component::Normal(part) => {
448 Some(urlencoding::encode(&part.to_string_lossy()).into_owned())
449 }
450 Component::CurDir => Some(".".to_string()),
451 Component::ParentDir => Some("..".to_string()),
452 Component::RootDir | Component::Prefix(_) => None,
453 })
454 .collect::<Vec<_>>()
455 .join("/")
456}
457
458fn to_sarif_region(range: Range) -> Region {
461 Region::builder()
462 .start_line(i64::from(range.start.line) + 1)
463 .start_column(i64::from(range.start.character) + 1)
464 .end_line(i64::from(range.end.line) + 1)
465 .end_column(i64::from(range.end.character) + 1)
466 .build()
467}
468
469fn to_result_level(severity: Severity) -> ResultLevel {
471 match severity {
472 Severity::Error => ResultLevel::Error,
473 Severity::Warning => ResultLevel::Warning,
474 Severity::Information | Severity::Hint => ResultLevel::Note,
475 }
476}
477
478pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
485 serde_json::to_string_pretty(&to_sarif(report))
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use deps_core::EcosystemId;
492 use deps_core::position::Position;
493 use std::path::PathBuf;
494
495 fn finding(category: Category, severity: Severity) -> CheckFinding {
496 CheckFinding {
497 ecosystem: EcosystemId::Cargo,
498 manifest_path: PathBuf::from("Cargo.toml"),
499 dependency_name: Some("serde".to_string()),
500 requirement: Some("1.0".to_string()),
501 category,
502 code: None,
503 advisory_url: None,
504 advisory_severity: None,
505 severity,
506 range: Range::new(Position::new(4, 0), Position::new(4, 10)),
507 message: "Newer version available: 1.1.0".to_string(),
508 }
509 }
510
511 fn finding_with_code(category: Category, code: &str, message: &str) -> CheckFinding {
512 CheckFinding {
513 code: Some(code.to_string()),
514 message: message.to_string(),
515 ..finding(category, Severity::Warning)
516 }
517 }
518
519 #[test]
520 fn test_to_sarif_empty_report_has_one_empty_run() {
521 let sarif = to_sarif(&CheckReport::default());
522 assert_eq!(sarif.runs.len(), 1);
523 assert!(sarif.runs[0].results.as_ref().unwrap().is_empty());
524 assert!(sarif.runs[0].tool.driver.rules.as_ref().unwrap().is_empty());
525 }
526
527 #[test]
528 fn test_to_sarif_sets_tool_driver_name() {
529 let sarif = to_sarif(&CheckReport::default());
530 assert_eq!(sarif.runs[0].tool.driver.name, "deps-cli");
531 }
532
533 #[test]
534 fn test_to_sarif_rule_id_matches_category_token() {
535 let report = CheckReport {
536 findings: vec![finding(Category::Outdated, Severity::Hint)],
537 };
538 let sarif = to_sarif(&report);
539 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
540 assert_eq!(rules.len(), 1);
541 assert_eq!(rules[0].id, "outdated");
542
543 let results = sarif.runs[0].results.as_ref().unwrap();
544 assert_eq!(results[0].rule_id.as_deref(), Some("outdated"));
545 assert_eq!(results[0].rule_index, Some(0));
546 }
547
548 #[test]
549 fn test_to_sarif_deduplicates_rules_across_findings() {
550 let report = CheckReport {
551 findings: vec![
552 finding(Category::Outdated, Severity::Hint),
553 finding(Category::Outdated, Severity::Hint),
554 finding(Category::Vulnerable, Severity::Error),
555 ],
556 };
557 let sarif = to_sarif(&report);
558 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
559 assert_eq!(rules.len(), 2);
560 }
561
562 #[test]
563 fn test_to_sarif_translates_range_to_one_based_region() {
564 let report = CheckReport {
565 findings: vec![finding(Category::Outdated, Severity::Hint)],
566 };
567 let sarif = to_sarif(&report);
568 let locations = sarif.runs[0].results.as_ref().unwrap()[0]
569 .locations
570 .as_ref()
571 .unwrap();
572 let region = locations[0]
573 .physical_location
574 .as_ref()
575 .unwrap()
576 .region
577 .as_ref()
578 .unwrap();
579 assert_eq!(region.start_line, Some(5));
580 assert_eq!(region.start_column, Some(1));
581 assert_eq!(region.end_line, Some(5));
582 assert_eq!(region.end_column, Some(11));
583 }
584
585 #[test]
586 fn test_to_sarif_artifact_uri_matches_manifest_path() {
587 let report = CheckReport {
588 findings: vec![finding(Category::Outdated, Severity::Hint)],
589 };
590 let sarif = to_sarif(&report);
591 let locations = sarif.runs[0].results.as_ref().unwrap()[0]
592 .locations
593 .as_ref()
594 .unwrap();
595 let artifact_location = locations[0]
596 .physical_location
597 .as_ref()
598 .unwrap()
599 .artifact_location
600 .as_ref()
601 .unwrap();
602 assert_eq!(artifact_location.uri.as_deref(), Some("Cargo.toml"));
603 }
604
605 #[test]
606 fn test_manifest_uri_percent_encodes_hash_and_space() {
607 let path = Path::new("a b#c%20d").join("Cargo.toml");
608 let uri = manifest_uri(&path);
609 assert_eq!(uri, "a%20b%23c%2520d/Cargo.toml");
610 assert!(
611 !uri.contains('#'),
612 "a literal '#' would be read as a URI fragment separator"
613 );
614 assert!(
615 !uri.contains(' '),
616 "a literal space is not valid in a bare URI-reference"
617 );
618 }
619
620 #[test]
621 fn test_manifest_uri_joins_nested_components_with_forward_slash() {
622 let path = Path::new("crates").join("deps-cli").join("Cargo.toml");
623 assert_eq!(manifest_uri(&path), "crates/deps-cli/Cargo.toml");
624 }
625
626 #[cfg(unix)]
629 #[test]
630 fn test_manifest_uri_drops_leading_root_dir_for_an_absolute_unix_path() {
631 let path = Path::new("/tmp/deps-cli-manual-test/Cargo.toml");
632 assert!(
633 path.is_absolute(),
634 "test setup bug: fixture path must be absolute"
635 );
636 let uri = manifest_uri(path);
637 assert_eq!(uri, "tmp/deps-cli-manual-test/Cargo.toml");
638 assert!(
639 !Path::new(&uri).is_absolute(),
640 "an absolute manifest_path must not leak into an absolute artifactLocation.uri \
641 (spec 062 review R1)"
642 );
643 }
644
645 #[cfg(windows)]
646 #[test]
647 fn test_manifest_uri_drops_leading_prefix_and_root_dir_for_an_absolute_windows_path() {
648 let path = Path::new(r"C:\tmp\deps-cli-manual-test\Cargo.toml");
649 assert!(
650 path.is_absolute(),
651 "test setup bug: fixture path must be absolute"
652 );
653 let uri = manifest_uri(path);
654 assert_eq!(uri, "tmp/deps-cli-manual-test/Cargo.toml");
655 assert!(
656 !Path::new(&uri).is_absolute(),
657 "an absolute manifest_path must not leak into an absolute artifactLocation.uri \
658 (spec 062 review R1)"
659 );
660 }
661
662 #[test]
663 fn test_to_sarif_artifact_uri_of_nested_path_has_no_fragment_character() {
664 let mut finding = finding(Category::Outdated, Severity::Hint);
665 finding.manifest_path = Path::new("a b#c").join("Cargo.toml");
666 let report = CheckReport {
667 findings: vec![finding],
668 };
669 let sarif = to_sarif(&report);
670 let locations = sarif.runs[0].results.as_ref().unwrap()[0]
671 .locations
672 .as_ref()
673 .unwrap();
674 let uri = locations[0]
675 .physical_location
676 .as_ref()
677 .unwrap()
678 .artifact_location
679 .as_ref()
680 .unwrap()
681 .uri
682 .as_ref()
683 .unwrap();
684 assert!(!uri.contains('#'));
685 assert!(!uri.contains(' '));
686 }
687
688 #[test]
689 fn test_to_sarif_severity_level_mapping() {
690 let report = CheckReport {
691 findings: vec![
692 finding(Category::Vulnerable, Severity::Error),
693 finding(Category::License, Severity::Warning),
694 finding(Category::Deprecated, Severity::Hint),
695 ],
696 };
697 let sarif = to_sarif(&report);
698 let results = sarif.runs[0].results.as_ref().unwrap();
699 assert_eq!(results[0].level, Some(ResultLevel::Error));
700 assert_eq!(results[1].level, Some(ResultLevel::Warning));
701 assert_eq!(results[2].level, Some(ResultLevel::Note));
702 }
703
704 #[test]
705 fn test_render_round_trips_through_serde_json() {
706 let report = CheckReport {
707 findings: vec![finding(Category::Outdated, Severity::Hint)],
708 };
709 let rendered = render(&report).expect("render must succeed");
710 let parsed: Sarif = serde_json::from_str(&rendered).expect("must round-trip");
711 assert_eq!(parsed.version, to_sarif(&report).version);
712 }
713
714 #[test]
722 fn test_to_sarif_multi_category_snapshot() {
723 let report = CheckReport {
724 findings: vec![
725 finding(Category::Outdated, Severity::Hint),
726 finding(Category::Vulnerable, Severity::Error),
727 ],
728 };
729 insta::assert_json_snapshot!(to_sarif(&report), {
730 ".runs[0].automationDetails.id" => "[automation_id]",
731 });
732 }
733
734 #[test]
735 fn test_to_sarif_rule_id_uses_code_when_present() {
736 let report = CheckReport {
737 findings: vec![finding_with_code(
738 Category::Vulnerable,
739 "RUSTSEC-2020-0071",
740 "RUSTSEC-2020-0071: Potential segfault in the time crate",
741 )],
742 };
743 let sarif = to_sarif(&report);
744 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
745 assert_eq!(rules.len(), 1);
746 assert_eq!(rules[0].id, "RUSTSEC-2020-0071");
747
748 let results = sarif.runs[0].results.as_ref().unwrap();
749 assert_eq!(results[0].rule_id.as_deref(), Some("RUSTSEC-2020-0071"));
750 }
751
752 #[test]
753 fn test_to_sarif_distinct_advisory_codes_produce_distinct_rules() {
754 let report = CheckReport {
755 findings: vec![
756 finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "advisory A"),
757 finding_with_code(Category::Vulnerable, "GHSA-xxxx-yyyy-zzzz", "advisory B"),
758 ],
759 };
760 let sarif = to_sarif(&report);
761 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
762 assert_eq!(
763 rules.len(),
764 2,
765 "two distinct advisories must not collapse into one rule"
766 );
767 }
768
769 #[test]
770 fn test_to_sarif_advisory_overflow_line_falls_back_to_category_rule() {
771 let report = CheckReport {
774 findings: vec![finding(Category::Vulnerable, Severity::Information)],
775 };
776 let sarif = to_sarif(&report);
777 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
778 assert_eq!(rules[0].id, "vulnerable");
779 }
780
781 #[test]
786 fn test_to_sarif_coded_non_vulnerable_finding_still_uses_category_rule_id() {
787 let report = CheckReport {
788 findings: vec![finding_with_code(
789 Category::Unsatisfiable,
790 "unsatisfiable-requirement",
791 "no matching version",
792 )],
793 };
794 let sarif = to_sarif(&report);
795 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
796 assert_eq!(rules.len(), 1);
797 assert_eq!(rules[0].id, "unsatisfiable");
798 assert!(rules[0].help_uri.is_none());
799 assert!(rules[0].full_description.is_none());
800
801 let results = sarif.runs[0].results.as_ref().unwrap();
802 assert_eq!(results[0].rule_id.as_deref(), Some("unsatisfiable"));
803 }
804
805 #[test]
809 fn test_to_sarif_mutable_ref_pin_does_not_split_on_differing_codes() {
810 let report = CheckReport {
811 findings: vec![
812 finding_with_code(
813 Category::MutableRefPin,
814 "mutable-ref-pin",
815 "pinned to a tag",
816 ),
817 finding_with_code(
818 Category::MutableRefPin,
819 "gitlab-ci-mutable-ref-pin",
820 "pinned to a tag",
821 ),
822 ],
823 };
824 let sarif = to_sarif(&report);
825 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
826 assert_eq!(
827 rules.len(),
828 1,
829 "MutableRefPin's two internal code constants must not fragment one category into two rules"
830 );
831 assert_eq!(rules[0].id, "mutable-ref");
832 }
833
834 #[test]
835 fn test_build_rule_descriptor_advisory_rule_has_help_uri_and_full_description() {
836 let report = CheckReport {
837 findings: vec![finding_with_code(
838 Category::Vulnerable,
839 "RUSTSEC-2020-0071",
840 "RUSTSEC-2020-0071: Potential segfault in the time crate",
841 )],
842 };
843 let sarif = to_sarif(&report);
844 let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
845 assert_eq!(
846 rule.help_uri.as_deref(),
847 Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071")
848 );
849 assert_eq!(
850 rule.full_description.as_ref().unwrap().text,
851 "RUSTSEC-2020-0071: Potential segfault in the time crate"
852 );
853 assert_eq!(
854 rule.short_description.as_ref().unwrap().text,
855 Category::Vulnerable.description()
856 );
857 assert_eq!(rule.name.as_deref(), Some("RUSTSEC-2020-0071"));
858 }
859
860 #[test]
861 fn test_build_rule_descriptor_category_only_rule_has_no_help_uri_or_full_description() {
862 let report = CheckReport {
863 findings: vec![finding(Category::Outdated, Severity::Hint)],
864 };
865 let sarif = to_sarif(&report);
866 let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
867 assert!(
868 rule.help_uri.is_none(),
869 "a category-only rule has no natural URL to fabricate one for"
870 );
871 assert!(rule.full_description.is_none());
872 assert!(rule.properties.is_none());
873 assert_eq!(
874 rule.short_description.as_ref().unwrap().text,
875 Category::Outdated.description()
876 );
877 }
878
879 #[test]
880 fn test_build_rule_descriptor_prefers_advisory_url_over_derived_formula() {
881 let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
882 finding.advisory_url =
883 Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071?utm=x".to_string());
884 let report = CheckReport {
885 findings: vec![finding],
886 };
887 let sarif = to_sarif(&report);
888 let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
889 assert_eq!(
890 rule.help_uri.as_deref(),
891 Some("https://osv.dev/vulnerability/RUSTSEC-2020-0071?utm=x"),
892 "the authoritative OSV-provided href must win over the derived formula"
893 );
894 }
895
896 #[test]
899 fn test_build_rule_descriptor_omits_help_uri_for_a_malformed_advisory_id() {
900 let report = CheckReport {
901 findings: vec![finding_with_code(
902 Category::Vulnerable,
903 "RUSTSEC with a space",
904 "msg",
905 )],
906 };
907 let sarif = to_sarif(&report);
908 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
909 assert_eq!(
910 rules.len(),
911 1,
912 "a code failing the allowlist must fall back to the category-token rule, not \
913 become its own rule id (issue #1077 review #2)"
914 );
915 assert_eq!(rules[0].id, "vulnerable");
916 assert!(
917 rules[0].help_uri.is_none(),
918 "a malformed advisory id must not become an unvalidated helpUri"
919 );
920
921 let results = sarif.runs[0].results.as_ref().unwrap();
922 assert_eq!(results[0].rule_id.as_deref(), Some("vulnerable"));
923 }
924
925 #[test]
930 fn test_build_rule_descriptor_omits_help_uri_for_a_traversal_id() {
931 let report = CheckReport {
932 findings: vec![finding_with_code(Category::Vulnerable, "..", "msg")],
933 };
934 let sarif = to_sarif(&report);
935 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
936 assert_eq!(rules[0].id, "vulnerable");
937 assert!(rules[0].help_uri.is_none());
938 }
939
940 #[test]
943 fn test_build_rule_descriptor_omits_help_uri_for_an_embedded_slash_traversal() {
944 let report = CheckReport {
945 findings: vec![finding_with_code(Category::Vulnerable, "../evil", "msg")],
946 };
947 let sarif = to_sarif(&report);
948 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
949 assert_eq!(rules[0].id, "vulnerable");
950 assert!(rules[0].help_uri.is_none());
951 }
952
953 #[test]
958 fn test_build_rule_descriptor_advisory_id_equal_to_a_category_token_is_still_advisory() {
959 let report = CheckReport {
960 findings: vec![finding_with_code(Category::Vulnerable, "vulnerable", "msg")],
961 };
962 let sarif = to_sarif(&report);
963 let rules = sarif.runs[0].tool.driver.rules.as_ref().unwrap();
964 assert_eq!(rules.len(), 1);
965 assert_eq!(rules[0].id, "vulnerable");
966 assert_eq!(rules[0].name.as_deref(), Some("vulnerable"));
967 assert_eq!(
968 rules[0].help_uri.as_deref(),
969 Some("https://osv.dev/vulnerability/vulnerable"),
970 "a valid code equal to the category token must still be trusted as an advisory id"
971 );
972 assert_eq!(rules[0].full_description.as_ref().unwrap().text, "msg");
973 }
974
975 #[test]
976 fn test_build_rule_descriptor_sets_security_severity_from_advisory_bucket() {
977 let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
978 finding.advisory_severity = Some(VulnSeverity::Critical);
979 let report = CheckReport {
980 findings: vec![finding],
981 };
982 let sarif = to_sarif(&report);
983 let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
984 let properties = rule.properties.as_ref().expect("properties must be set");
985 assert_eq!(
986 properties.additional_properties.get("security-severity"),
987 Some(&serde_json::Value::String("9.5".to_string()))
988 );
989 }
990
991 #[test]
992 fn test_build_rule_descriptor_omits_security_severity_for_an_ungraded_bucket() {
993 let mut finding = finding_with_code(Category::Vulnerable, "RUSTSEC-2020-0071", "msg");
994 finding.advisory_severity = Some(VulnSeverity::Unknown);
995 let report = CheckReport {
996 findings: vec![finding],
997 };
998 let sarif = to_sarif(&report);
999 let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
1000 assert!(rule.properties.is_none());
1001 }
1002
1003 #[test]
1004 fn test_build_rule_descriptor_omits_security_severity_without_advisory_severity_data() {
1005 let report = CheckReport {
1006 findings: vec![finding_with_code(
1007 Category::Vulnerable,
1008 "RUSTSEC-2020-0071",
1009 "msg",
1010 )],
1011 };
1012 let sarif = to_sarif(&report);
1013 let rule = &sarif.runs[0].tool.driver.rules.as_ref().unwrap()[0];
1014 assert!(rule.properties.is_none());
1015 }
1016
1017 #[test]
1018 fn test_to_sarif_partial_fingerprint_is_stable_across_a_line_shift() {
1019 let mut moved = finding(Category::Outdated, Severity::Hint);
1020 moved.range = Range::new(Position::new(40, 0), Position::new(40, 10));
1021 let report_before = CheckReport {
1022 findings: vec![finding(Category::Outdated, Severity::Hint)],
1023 };
1024 let report_after = CheckReport {
1025 findings: vec![moved],
1026 };
1027
1028 let fingerprint_before = sarif_fingerprint(&report_before);
1029 let fingerprint_after = sarif_fingerprint(&report_after);
1030 assert_eq!(
1031 fingerprint_before, fingerprint_after,
1032 "an unrelated line shift must not change the fingerprint"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_to_sarif_partial_fingerprint_differs_across_dependency_and_category() {
1038 let base = sarif_fingerprint(&CheckReport {
1039 findings: vec![finding(Category::Outdated, Severity::Hint)],
1040 });
1041
1042 let mut other_dependency = finding(Category::Outdated, Severity::Hint);
1043 other_dependency.dependency_name = Some("tokio".to_string());
1044 let other_dependency_fp = sarif_fingerprint(&CheckReport {
1045 findings: vec![other_dependency],
1046 });
1047 assert_ne!(base, other_dependency_fp);
1048
1049 let other_category_fp = sarif_fingerprint(&CheckReport {
1050 findings: vec![finding(Category::Vulnerable, Severity::Error)],
1051 });
1052 assert_ne!(base, other_category_fp);
1053 }
1054
1055 #[test]
1060 fn test_to_sarif_partial_fingerprint_disambiguates_duplicate_occurrences_by_ordinal() {
1061 let report = CheckReport {
1062 findings: vec![
1063 finding(Category::Outdated, Severity::Hint),
1064 finding(Category::Outdated, Severity::Hint),
1065 ],
1066 };
1067 let sarif = to_sarif(&report);
1068 let results = sarif.runs[0].results.as_ref().unwrap();
1069 let fp0 = result_fingerprint(&results[0]);
1070 let fp1 = result_fingerprint(&results[1]);
1071 assert_ne!(
1072 fp0, fp1,
1073 "two occurrences of the same (manifest, dependency, rule) must not collapse"
1074 );
1075 }
1076
1077 #[test]
1081 fn test_to_sarif_partial_fingerprint_disambiguates_two_document_level_other_notices() {
1082 let mut first = finding(Category::Other, Severity::Information);
1083 first.dependency_name = None;
1084 let mut second = finding(Category::Other, Severity::Information);
1085 second.dependency_name = None;
1086 let report = CheckReport {
1087 findings: vec![first, second],
1088 };
1089 let sarif = to_sarif(&report);
1090 let results = sarif.runs[0].results.as_ref().unwrap();
1091 assert_ne!(
1092 result_fingerprint(&results[0]),
1093 result_fingerprint(&results[1])
1094 );
1095 }
1096
1097 #[test]
1101 fn test_to_sarif_partial_fingerprint_percent_encodes_a_pipe_in_the_dependency_name() {
1102 let mut finding = finding(Category::Outdated, Severity::Hint);
1103 finding.dependency_name = Some("serde|outdated".to_string());
1104 let report = CheckReport {
1105 findings: vec![finding],
1106 };
1107 let sarif = to_sarif(&report);
1108 let fp = result_fingerprint(&sarif.runs[0].results.as_ref().unwrap()[0]);
1109 assert!(
1110 !fp.contains("serde|outdated"),
1111 "a literal delimiter inside a component must be percent-encoded, not passed through raw"
1112 );
1113 assert!(fp.contains("serde%7Coutdated"));
1114 }
1115
1116 #[test]
1117 fn test_to_sarif_partial_fingerprint_handles_missing_dependency_name() {
1118 let mut finding = finding(Category::Other, Severity::Information);
1119 finding.dependency_name = None;
1120 let report = CheckReport {
1121 findings: vec![finding],
1122 };
1123 let sarif = to_sarif(&report);
1124 let fp = result_fingerprint(&sarif.runs[0].results.as_ref().unwrap()[0]);
1125 assert!(!fp.is_empty());
1126 }
1127
1128 fn sarif_fingerprint(report: &CheckReport) -> String {
1129 result_fingerprint(&to_sarif(report).runs[0].results.as_ref().unwrap()[0])
1130 }
1131
1132 fn result_fingerprint(result: &SarifResult) -> String {
1133 result
1134 .partial_fingerprints
1135 .as_ref()
1136 .unwrap()
1137 .get("depsCli/v1")
1138 .unwrap()
1139 .clone()
1140 }
1141
1142 #[test]
1143 fn test_automation_id_category_is_stable_and_run_id_is_last_segment() {
1144 let id = automation_id_with_env(|name| match name {
1145 "GITHUB_RUN_ID" => Some("12345".to_string()),
1146 "GITHUB_RUN_ATTEMPT" => Some("2".to_string()),
1147 "GITHUB_WORKFLOW" => Some("CI".to_string()),
1148 "GITHUB_JOB" => Some("test".to_string()),
1149 _ => None,
1150 });
1151 assert_eq!(id, "deps-cli/CI/test/12345-2");
1152 let (category, run) = id.rsplit_once('/').expect("id must contain a separator");
1153 assert_eq!(category, "deps-cli/CI/test");
1154 assert_eq!(run, "12345-2");
1155 }
1156
1157 #[test]
1162 fn test_automation_id_category_is_stable_across_two_runs_of_the_same_workflow_and_job() {
1163 let env_for = |run_id: &'static str| {
1164 move |name: &str| match name {
1165 "GITHUB_RUN_ID" => Some(run_id.to_string()),
1166 "GITHUB_WORKFLOW" => Some("CI".to_string()),
1167 "GITHUB_JOB" => Some("test".to_string()),
1168 _ => None,
1169 }
1170 };
1171 let first = automation_id_with_env(env_for("111"));
1172 let second = automation_id_with_env(env_for("222"));
1173 let (first_category, _) = first.rsplit_once('/').unwrap();
1174 let (second_category, _) = second.rsplit_once('/').unwrap();
1175 assert_eq!(
1176 first_category, second_category,
1177 "category must stay stable across runs so a later upload supersedes an earlier one"
1178 );
1179 assert_ne!(first, second, "the run id itself must still vary");
1180 }
1181
1182 #[test]
1183 fn test_automation_id_run_attempt_unset_still_uses_run_id_alone() {
1184 let id = automation_id_with_env(|name| match name {
1185 "GITHUB_RUN_ID" => Some("12345".to_string()),
1186 "GITHUB_WORKFLOW" => Some("CI".to_string()),
1187 "GITHUB_JOB" => Some("test".to_string()),
1188 _ => None,
1189 });
1190 assert_eq!(id, "deps-cli/CI/test/12345");
1191 }
1192
1193 #[test]
1194 fn test_automation_id_falls_back_without_github_run_id() {
1195 let id = automation_id_with_env(|_| None);
1196 assert_eq!(id, "deps-cli/local");
1197 }
1198
1199 #[test]
1200 fn test_to_sarif_sets_automation_details_id() {
1201 let sarif = to_sarif(&CheckReport::default());
1202 assert!(
1203 sarif.runs[0]
1204 .automation_details
1205 .as_ref()
1206 .and_then(|details| details.id.as_deref())
1207 .is_some_and(|id| !id.is_empty())
1208 );
1209 }
1210}