1use std::path::Path;
71
72use serde::Deserialize;
73
74use crate::adapter::{Adapter, AssetPaths, InstallHint, Invocation, NativeContext};
75use crate::guidance::{Guidance, Line};
76use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
77use crate::runner::ExecError;
78use rto_graph::Severity;
79
80pub const ANALYZER: &str = "osv-scanner";
82
83pub const DB_ASSET: &str = "osv-db";
85
86const INSTALL_HINTS: &[InstallHint] = &[InstallHint {
104 program: "osv-scanner",
105 guidance: Guidance::new(&[
106 Line::Note(&[
107 "Roteiro does not install analyzers, and has not installed this one.",
108 "osv-scanner documents no single install command — its page lists a",
109 "prebuilt binary and one entry per platform, so there is nothing to",
110 "paste here; choose the entry that fits this host.",
111 ]),
112 Line::Note(&["Upstream: https://google.github.io/osv-scanner/installation/"]),
113 ]),
114}];
115
116pub const UNKNOWN_SOURCE: &str = "unknown-source";
126
127#[derive(Debug, Clone, Copy)]
129pub struct OsvScanner;
130
131impl Adapter for OsvScanner {
132 fn analyzer(&self) -> &'static str {
133 ANALYZER
134 }
135
136 fn summary(&self) -> &'static str {
137 "OSV.dev advisories against resolved lockfiles (Python, Java, Node, Rust dependencies)"
138 }
139
140 fn languages(&self) -> &'static [&'static str] {
141 &["python", "java", "javascript", "typescript", "rust"]
146 }
147
148 fn asset_ids(&self) -> &'static [&'static str] {
149 &[DB_ASSET]
150 }
151
152 fn host_programs(&self) -> &'static [&'static str] {
153 &["osv-scanner"]
154 }
155
156 fn install_hints(&self) -> &'static [InstallHint] {
157 INSTALL_HINTS
158 }
159
160 fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
161 Invocation {
162 program: "osv-scanner".to_owned(),
163 args: vec![
164 "scan".to_owned(),
165 "source".to_owned(),
166 "--offline".to_owned(),
172 "--local-db-path".to_owned(),
173 assets.arg(DB_ASSET),
174 "--format".to_owned(),
175 "json".to_owned(),
176 "--recursive".to_owned(),
177 ".".to_owned(),
181 ],
182 success_statuses: vec![0, 1],
186 }
187 }
188
189 fn normalize(
190 &self,
191 native: &[u8],
192 ctx: &NativeContext<'_>,
193 ) -> Result<NormalizedReport, ExecError> {
194 let output: ScanOutput = serde_json::from_slice(native)?;
195 let Some(results) = output.results else {
196 return Err(ExecError::MalformedReport(
197 "not an osv-scanner report: no `results` array".to_owned(),
198 ));
199 };
200
201 let mut findings = Vec::new();
202 for result in &results {
203 let source = source_component(result.source.path.as_deref(), ctx.worktree);
204 for entry in &result.packages {
205 convert_package(entry, &source, &mut findings)?;
206 }
207 }
208
209 Ok(NormalizedReport {
210 schema: REPORT_SCHEMA.to_owned(),
211 analyzer: ANALYZER.to_owned(),
212 analyzer_version: ctx.version_or(None),
216 started_at: ctx.started_at.clone(),
217 ended_at: ctx.ended_at.clone(),
218 exit_status: ctx.exit_status,
219 rules_digest: None,
221 image_digest: None,
222 advisory_db: ctx.advisory_db.clone(),
226 source: ctx.source.clone(),
227 findings,
228 })
229 }
230}
231
232fn convert_package(
234 entry: &PackageEntry,
235 source: &str,
236 into: &mut Vec<ReportFinding>,
237) -> Result<(), ExecError> {
238 let Some(package) = entry.package.as_ref() else {
239 return Err(ExecError::MalformedReport(
240 "an osv-scanner package entry has no `package` object".to_owned(),
241 ));
242 };
243 if package.name.trim().is_empty() {
244 return Err(ExecError::MalformedReport(
245 "an osv-scanner package entry has an unnamed package".to_owned(),
246 ));
247 }
248 let version = if package.version.trim().is_empty() {
249 "unknown-version"
250 } else {
251 package.version.trim()
252 };
253 let ecosystem = if package.ecosystem.trim().is_empty() {
254 "unknown-ecosystem"
255 } else {
256 package.ecosystem.trim()
257 };
258
259 for group in groups_of(entry) {
260 let mut ids: Vec<&str> = group
264 .ids
265 .iter()
266 .map(|id| id.trim())
267 .filter(|id| !id.is_empty())
268 .collect();
269 ids.sort_unstable();
270 ids.dedup();
271 let Some(&rule) = ids.first() else {
272 return Err(ExecError::MalformedReport(
273 "an osv-scanner group names no advisory id".to_owned(),
274 ));
275 };
276
277 let members: Vec<&Vulnerability> = entry
281 .vulnerabilities
282 .iter()
283 .filter(|v| ids.contains(&v.id.trim()))
284 .collect();
285
286 let aliases = alias_set(&group, &members);
287 let title = members
288 .iter()
289 .filter_map(|v| v.summary.as_deref())
290 .map(str::trim)
291 .find(|s| !s.is_empty())
292 .map_or_else(
293 || format!("{} {version} is affected by {rule}", package.name),
294 str::to_owned,
295 );
296 let message = members
297 .iter()
298 .filter_map(|v| v.details.as_deref())
299 .map(str::trim)
300 .find(|s| !s.is_empty())
301 .unwrap_or_default()
302 .to_owned();
303
304 into.push(ReportFinding {
305 identity: vec![
309 rule.to_owned(),
310 ecosystem.to_owned(),
311 package.name.clone(),
312 version.to_owned(),
313 source.to_owned(),
314 ],
315 rule: rule.to_owned(),
316 severity: severity(&members),
317 title,
318 message,
319 path: (source != UNKNOWN_SOURCE).then(|| source.to_owned()),
322 span: None,
323 meta: serde_json::json!({
324 "ecosystem": ecosystem,
325 "package": package.name,
326 "version": version,
327 "aliases": aliases,
331 "ids": ids,
332 "max_severity": group.max_severity,
335 "informational": informational(&members),
337 "cvss": cvss_vectors(&members),
338 "withdrawn": members.iter().find_map(|v| v.withdrawn.clone()),
339 "source": source,
343 }),
344 });
345 }
346 Ok(())
347}
348
349fn groups_of(entry: &PackageEntry) -> Vec<Group> {
356 if !entry.groups.is_empty() {
357 return entry.groups.clone();
358 }
359 entry
360 .vulnerabilities
361 .iter()
362 .map(|v| Group {
363 ids: vec![v.id.clone()],
364 aliases: v.aliases.clone(),
365 max_severity: String::new(),
366 })
367 .collect()
368}
369
370fn alias_set(group: &Group, members: &[&Vulnerability]) -> Vec<String> {
373 let mut all: Vec<String> = group
374 .ids
375 .iter()
376 .chain(group.aliases.iter())
377 .chain(members.iter().flat_map(|v| v.aliases.iter()))
378 .chain(members.iter().map(|v| &v.id))
379 .map(|id| id.trim().to_owned())
380 .filter(|id| !id.is_empty())
381 .collect();
382 all.sort();
383 all.dedup();
384 all
385}
386
387fn informational(members: &[&Vulnerability]) -> Option<String> {
389 members
390 .iter()
391 .flat_map(|v| v.affected.iter())
392 .filter_map(|a| a.database_specific.as_ref())
393 .filter_map(|d| d.informational.as_deref())
394 .map(str::trim)
395 .find(|k| !k.is_empty())
396 .map(str::to_owned)
397}
398
399fn cvss_vectors(members: &[&Vulnerability]) -> Vec<String> {
401 let mut out: Vec<String> = members
402 .iter()
403 .flat_map(|v| v.severity.iter())
404 .map(|s| s.score.trim().to_owned())
405 .filter(|s| !s.is_empty())
406 .collect();
407 out.sort();
408 out.dedup();
409 out
410}
411
412fn severity(members: &[&Vulnerability]) -> Severity {
418 let mut best: Option<Severity> = None;
419 for member in members {
420 for level in member_levels(member) {
421 if best.as_ref().is_none_or(|b| rank(&level) > rank(b)) {
422 best = Some(level);
423 }
424 }
425 }
426 best.unwrap_or(Severity::High)
430}
431
432fn member_levels(member: &Vulnerability) -> Vec<Severity> {
434 let mut levels = Vec::new();
435 if let Some(token) = member
436 .database_specific
437 .as_ref()
438 .and_then(|d| d.severity.as_deref())
439 .map(str::trim)
440 .filter(|s| !s.is_empty())
441 {
442 levels.push(from_github(token));
443 }
444 for affected in &member.affected {
445 if let Some(kind) = affected
446 .database_specific
447 .as_ref()
448 .and_then(|d| d.informational.as_deref())
449 .map(str::trim)
450 .filter(|k| !k.is_empty())
451 {
452 levels.push(from_informational(kind));
453 }
454 }
455 levels
456}
457
458fn from_github(token: &str) -> Severity {
460 match token.to_ascii_lowercase().as_str() {
461 "critical" => Severity::Critical,
462 "high" => Severity::High,
463 "moderate" | "medium" => Severity::Medium,
465 "low" => Severity::Low,
466 other => Severity::from_token(other),
467 }
468}
469
470fn from_informational(kind: &str) -> Severity {
472 match kind {
473 "unsound" => Severity::Medium,
474 "unmaintained" | "yanked" => Severity::Low,
475 "notice" => Severity::Info,
476 other => Severity::from_token(other),
477 }
478}
479
480fn rank(severity: &Severity) -> u8 {
483 match severity {
484 Severity::Critical => 5,
485 Severity::High => 4,
486 Severity::Medium => 3,
487 Severity::Low => 2,
488 Severity::Info => 1,
489 Severity::Other(_) => 0,
490 }
491}
492
493fn source_component(reported: Option<&str>, worktree: Option<&Path>) -> String {
500 let Some(reported) = reported.map(str::trim).filter(|p| !p.is_empty()) else {
501 return UNKNOWN_SOURCE.to_owned();
502 };
503 let path = Path::new(reported);
504 if path.is_relative() {
505 return normalise(reported);
506 }
507 let Some(worktree) = worktree else {
508 return UNKNOWN_SOURCE.to_owned();
509 };
510 let candidates = [
514 Some(worktree.to_path_buf()),
515 std::fs::canonicalize(worktree).ok(),
516 ];
517 for candidate in candidates.into_iter().flatten() {
518 if let Ok(relative) = path.strip_prefix(&candidate) {
519 let relative = relative.to_string_lossy();
520 if !relative.is_empty() {
521 return normalise(&relative);
522 }
523 }
524 }
525 UNKNOWN_SOURCE.to_owned()
526}
527
528fn normalise(path: &str) -> String {
530 path.replace('\\', "/")
531}
532
533#[derive(Debug, Deserialize)]
537struct ScanOutput {
538 #[serde(default)]
541 results: Option<Vec<ScanResult>>,
542}
543
544#[derive(Debug, Deserialize)]
545struct ScanResult {
546 #[serde(default)]
547 source: Source,
548 #[serde(default)]
549 packages: Vec<PackageEntry>,
550}
551
552#[derive(Debug, Default, Deserialize)]
553struct Source {
554 #[serde(default)]
555 path: Option<String>,
556}
557
558#[derive(Debug, Deserialize)]
559struct PackageEntry {
560 #[serde(default)]
561 package: Option<PackageId>,
562 #[serde(default)]
563 vulnerabilities: Vec<Vulnerability>,
564 #[serde(default)]
565 groups: Vec<Group>,
566}
567
568#[derive(Debug, Deserialize)]
569struct PackageId {
570 #[serde(default)]
571 name: String,
572 #[serde(default)]
573 version: String,
574 #[serde(default)]
575 ecosystem: String,
576}
577
578#[derive(Debug, Clone, Deserialize)]
579struct Group {
580 #[serde(default)]
581 ids: Vec<String>,
582 #[serde(default)]
583 aliases: Vec<String>,
584 #[serde(default)]
585 max_severity: String,
586}
587
588#[derive(Debug, Deserialize)]
589struct Vulnerability {
590 #[serde(default)]
591 id: String,
592 #[serde(default)]
593 summary: Option<String>,
594 #[serde(default)]
595 details: Option<String>,
596 #[serde(default)]
597 aliases: Vec<String>,
598 #[serde(default)]
599 withdrawn: Option<String>,
600 #[serde(default)]
601 severity: Vec<SeverityScore>,
602 #[serde(default)]
603 database_specific: Option<DatabaseSpecific>,
604 #[serde(default)]
605 affected: Vec<Affected>,
606}
607
608#[derive(Debug, Deserialize)]
609struct SeverityScore {
610 #[serde(default)]
611 score: String,
612}
613
614#[derive(Debug, Deserialize)]
615struct DatabaseSpecific {
616 #[serde(default)]
617 severity: Option<String>,
618}
619
620#[derive(Debug, Deserialize)]
621struct Affected {
622 #[serde(default)]
623 database_specific: Option<AffectedDatabaseSpecific>,
624}
625
626#[derive(Debug, Deserialize)]
627struct AffectedDatabaseSpecific {
628 #[serde(default)]
629 informational: Option<String>,
630}
631
632#[cfg(test)]
633mod tests {
634 use super::{ANALYZER, DB_ASSET, OsvScanner, UNKNOWN_SOURCE, source_component};
635 use crate::adapter::{Adapter, AssetPaths, NativeContext};
636 use crate::runner::ExecError;
637 use rto_graph::{Severity, SourceIdentity};
638
639 static SOURCE: std::sync::LazyLock<SourceIdentity> =
640 std::sync::LazyLock::new(SourceIdentity::default);
641
642 fn ctx(worktree: Option<&'static str>) -> NativeContext<'static> {
643 NativeContext {
644 started_at: "2026-08-16T09:00:00Z".to_owned(),
645 ended_at: "2026-08-16T09:00:06Z".to_owned(),
646 analyzer_version: Some("2.5.0".to_owned()),
647 exit_status: 1,
648 source: &SOURCE,
649 rules_digest: None,
650 advisory_db: None,
651 worktree: worktree.map(std::path::Path::new),
652 snippets: &crate::snippet::NoSnippets,
653 }
654 }
655
656 const NATIVE: &str = r#"{
661 "results": [
662 {
663 "source": {"path": "/repo/Cargo.lock", "type": "lockfile"},
664 "packages": [
665 {
666 "package": {"name": "openssl", "version": "0.10.55", "ecosystem": "crates.io"},
667 "vulnerabilities": [
668 {
669 "id": "RUSTSEC-2023-0072",
670 "summary": "`openssl` `X509StoreRef::objects` is unsound",
671 "details": "The objects method is unsound.",
672 "aliases": ["GHSA-xphf-cx8h-7q9g"],
673 "database_specific": {"license": "CC0-1.0"},
674 "affected": [{"database_specific": {"informational": "unsound", "cvss": null}}]
675 },
676 {
677 "id": "GHSA-xphf-cx8h-7q9g",
678 "summary": "`openssl` `X509StoreRef::objects` is unsound",
679 "aliases": ["RUSTSEC-2023-0072"],
680 "database_specific": {"severity": "MODERATE"},
681 "affected": [{"database_specific": {}}]
682 }
683 ],
684 "groups": [
685 {
686 "ids": ["RUSTSEC-2023-0072", "GHSA-xphf-cx8h-7q9g"],
687 "aliases": ["GHSA-xphf-cx8h-7q9g", "RUSTSEC-2023-0072"],
688 "max_severity": ""
689 }
690 ]
691 },
692 {
693 "package": {"name": "derivative", "version": "2.2.0", "ecosystem": "crates.io"},
694 "vulnerabilities": [
695 {
696 "id": "RUSTSEC-2024-0388",
697 "summary": "`derivative` is unmaintained; consider using an alternative",
698 "database_specific": {"license": "CC0-1.0"},
699 "affected": [{"database_specific": {"informational": "unmaintained"}}]
700 }
701 ],
702 "groups": [
703 {"ids": ["RUSTSEC-2024-0388"], "aliases": ["RUSTSEC-2024-0388"], "max_severity": ""}
704 ]
705 }
706 ]
707 },
708 {
709 "source": {"path": "/repo/app/package-lock.json", "type": "lockfile"},
710 "packages": [
711 {
712 "package": {"name": "lodash", "version": "4.17.15", "ecosystem": "npm"},
713 "vulnerabilities": [
714 {
715 "id": "GHSA-p6mc-m468-83gw",
716 "summary": "Prototype Pollution in lodash",
717 "details": "Versions prior to 4.17.21 are vulnerable.",
718 "aliases": ["CVE-2020-8203"],
719 "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N"}],
720 "database_specific": {"severity": "HIGH"},
721 "affected": [{"database_specific": {}}]
722 }
723 ],
724 "groups": [
725 {
726 "ids": ["GHSA-p6mc-m468-83gw"],
727 "aliases": ["CVE-2020-8203", "GHSA-p6mc-m468-83gw"],
728 "max_severity": "7.4"
729 }
730 ]
731 }
732 ]
733 }
734 ]
735 }"#;
736
737 #[test]
742 fn a_duplicated_advisory_becomes_one_finding_not_two() {
743 let report = OsvScanner
744 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
745 .expect("parse");
746 assert_eq!(report.analyzer, ANALYZER);
747 assert_eq!(report.findings.len(), 3, "openssl, derivative, lodash");
748 let openssl: Vec<_> = report
749 .findings
750 .iter()
751 .filter(|f| f.meta["package"] == "openssl")
752 .collect();
753 assert_eq!(openssl.len(), 1);
754 let aliases = openssl[0].meta["aliases"].as_array().expect("aliases");
756 assert!(aliases.iter().any(|a| a == "RUSTSEC-2023-0072"));
757 assert!(aliases.iter().any(|a| a == "GHSA-xphf-cx8h-7q9g"));
758 }
759
760 #[test]
761 fn uses_the_advisory_ecosystem_package_version_manifest_identity() {
762 let report = OsvScanner
763 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
764 .expect("parse");
765 let lodash = report
766 .findings
767 .iter()
768 .find(|f| f.meta["package"] == "lodash")
769 .expect("lodash");
770 assert_eq!(
771 lodash.identity,
772 vec![
773 "GHSA-p6mc-m468-83gw",
774 "npm",
775 "lodash",
776 "4.17.15",
777 "app/package-lock.json"
778 ]
779 );
780 assert_eq!(lodash.path.as_deref(), Some("app/package-lock.json"));
781 assert_eq!(lodash.severity, Severity::High);
782 assert_eq!(lodash.meta["max_severity"], "7.4");
783 }
784
785 #[test]
789 fn an_absolute_reported_path_is_made_worktree_relative() {
790 let report = OsvScanner
791 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
792 .expect("parse");
793 for finding in &report.findings {
794 let path = finding.path.as_deref().expect("a path");
795 assert!(!path.starts_with('/'), "{path} is still absolute");
796 crate::runner::check_reported_path(path).expect("must pass the preflight");
797 }
798 }
799
800 #[test]
804 fn a_path_outside_the_worktree_is_named_not_guessed() {
805 let report = OsvScanner
806 .normalize(NATIVE.as_bytes(), &ctx(Some("/elsewhere")))
807 .expect("parse");
808 assert!(report.findings.iter().all(|f| f.path.is_none()));
809 assert!(
810 report
811 .findings
812 .iter()
813 .all(|f| f.identity[4] == UNKNOWN_SOURCE)
814 );
815 }
816
817 #[test]
818 fn source_components_cover_relative_absolute_and_unknown() {
819 let repo = std::path::Path::new("/repo");
820 assert_eq!(
821 source_component(Some("/repo/a/Cargo.lock"), Some(repo)),
822 "a/Cargo.lock"
823 );
824 assert_eq!(
825 source_component(Some("a/Cargo.lock"), Some(repo)),
826 "a/Cargo.lock"
827 );
828 assert_eq!(
829 source_component(Some("/other/Cargo.lock"), Some(repo)),
830 UNKNOWN_SOURCE
831 );
832 assert_eq!(source_component(Some("/repo/x"), None), UNKNOWN_SOURCE);
833 assert_eq!(source_component(None, Some(repo)), UNKNOWN_SOURCE);
834 assert_eq!(source_component(Some(" "), Some(repo)), UNKNOWN_SOURCE);
835 }
836
837 #[test]
840 fn informational_kinds_are_graded_the_way_cargo_audit_grades_them() {
841 let report = OsvScanner
842 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
843 .expect("parse");
844 let unsound = report
845 .findings
846 .iter()
847 .find(|f| f.meta["package"] == "openssl")
848 .expect("openssl");
849 assert_eq!(unsound.severity, Severity::Medium);
850 assert_eq!(unsound.meta["informational"], "unsound");
851
852 let unmaintained = report
853 .findings
854 .iter()
855 .find(|f| f.meta["package"] == "derivative")
856 .expect("derivative");
857 assert_eq!(unmaintained.severity, Severity::Low);
858 assert_eq!(unmaintained.meta["informational"], "unmaintained");
859 }
860
861 #[test]
865 fn an_advisory_with_no_published_level_is_graded_high() {
866 let native = r#"{"results":[{"source":{"path":"/repo/Cargo.lock"},"packages":[
867 {"package":{"name":"x","version":"1.0.0","ecosystem":"crates.io"},
868 "vulnerabilities":[{"id":"OSV-1","summary":"bad"}],
869 "groups":[{"ids":["OSV-1"],"aliases":[],"max_severity":""}]}]}]}"#;
870 let report = OsvScanner
871 .normalize(native.as_bytes(), &ctx(Some("/repo")))
872 .expect("parse");
873 assert_eq!(report.findings[0].severity, Severity::High);
874 }
875
876 #[test]
879 fn a_group_takes_the_highest_level_its_records_publish() {
880 let native = r#"{"results":[{"source":{"path":"/repo/p.json"},"packages":[
881 {"package":{"name":"x","version":"1.0.0","ecosystem":"npm"},
882 "vulnerabilities":[
883 {"id":"A-1","summary":"a","database_specific":{"severity":"LOW"}},
884 {"id":"B-1","summary":"b","database_specific":{"severity":"CRITICAL"}}],
885 "groups":[{"ids":["A-1","B-1"],"aliases":[],"max_severity":"9.8"}]}]}]}"#;
886 let report = OsvScanner
887 .normalize(native.as_bytes(), &ctx(Some("/repo")))
888 .expect("parse");
889 assert_eq!(report.findings.len(), 1);
890 assert_eq!(report.findings[0].severity, Severity::Critical);
891 }
892
893 #[test]
897 fn the_same_advisory_in_two_manifests_is_two_findings() {
898 let native = r#"{"results":[
899 {"source":{"path":"/repo/a/package-lock.json"},"packages":[
900 {"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
901 "vulnerabilities":[{"id":"G-1","summary":"pollution"}],
902 "groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]},
903 {"source":{"path":"/repo/b/package-lock.json"},"packages":[
904 {"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
905 "vulnerabilities":[{"id":"G-1","summary":"pollution"}],
906 "groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]}]}"#;
907 let report = OsvScanner
908 .normalize(native.as_bytes(), &ctx(Some("/repo")))
909 .expect("parse");
910 assert_eq!(report.findings.len(), 2);
911 assert_ne!(report.findings[0].identity, report.findings[1].identity);
912 }
913
914 #[test]
918 fn vulnerabilities_without_groups_are_still_reported() {
919 let native = r#"{"results":[{"source":{"path":"/repo/req.txt"},"packages":[
920 {"package":{"name":"django","version":"2.2.0","ecosystem":"PyPI"},
921 "vulnerabilities":[{"id":"PYSEC-2019-10","summary":"sql injection"}]}]}]}"#;
922 let report = OsvScanner
923 .normalize(native.as_bytes(), &ctx(Some("/repo")))
924 .expect("parse");
925 assert_eq!(report.findings.len(), 1);
926 assert_eq!(report.findings[0].rule, "PYSEC-2019-10");
927 }
928
929 #[test]
930 fn a_clean_scan_is_a_valid_empty_report() {
931 let report = OsvScanner
932 .normalize(br#"{"results":[]}"#, &ctx(Some("/repo")))
933 .expect("parse");
934 assert!(report.findings.is_empty());
935 }
936
937 #[test]
938 fn refuses_output_that_is_not_an_osv_scanner_report() {
939 let err = OsvScanner
940 .normalize(br#"{"experimental_config":{}}"#, &ctx(Some("/repo")))
941 .expect_err("must be refused");
942 assert!(matches!(err, ExecError::MalformedReport(_)));
943 assert!(err.to_string().contains("no `results` array"), "{err}");
944
945 assert!(matches!(
946 OsvScanner.normalize(b"<html>", &ctx(Some("/repo"))),
947 Err(ExecError::Json(_))
948 ));
949 }
950
951 #[test]
952 fn refuses_a_package_entry_with_no_package() {
953 let native = r#"{"results":[{"source":{"path":"/repo/x"},"packages":[
954 {"vulnerabilities":[{"id":"A"}]}]}]}"#;
955 assert!(matches!(
956 OsvScanner.normalize(native.as_bytes(), &ctx(Some("/repo"))),
957 Err(ExecError::MalformedReport(_))
958 ));
959 }
960
961 #[test]
965 fn the_invocation_pins_the_database_and_really_goes_offline() {
966 let entries = [(DB_ASSET, std::path::PathBuf::from("/cache/osv"))];
967 let invocation = OsvScanner.command(&AssetPaths::new(&entries));
968 assert_eq!(invocation.program, "osv-scanner");
969 assert_eq!(invocation.args[0], "scan");
970 assert_eq!(invocation.args[1], "source");
971 assert!(invocation.args.contains(&"--offline".to_owned()));
972 assert!(
973 !invocation
974 .args
975 .contains(&"--offline-vulnerabilities".to_owned()),
976 "that flag alone consults nothing and reports a clean scan"
977 );
978 let db = invocation
979 .args
980 .iter()
981 .position(|a| a == "--local-db-path")
982 .map(|i| invocation.args[i + 1].clone())
983 .expect("a --local-db-path argument");
984 assert_eq!(db, "/cache/osv");
985 assert_eq!(invocation.args.last().map(String::as_str), Some("."));
987 assert_eq!(invocation.success_statuses, vec![0, 1]);
989 }
990
991 #[test]
992 fn covers_the_dependency_axis_for_the_ecosystems_it_provisions() {
993 assert!(OsvScanner.languages().contains(&"python"));
994 assert!(OsvScanner.languages().contains(&"java"));
995 assert!(OsvScanner.languages().contains(&"javascript"));
996 assert_eq!(OsvScanner.asset_ids(), &[DB_ASSET]);
997 }
998}