use std::path::Path;
use serde::Deserialize;
use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext};
use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
use crate::runner::ExecError;
use rto_graph::Severity;
pub const ANALYZER: &str = "osv-scanner";
pub const DB_ASSET: &str = "osv-db";
pub const UNKNOWN_SOURCE: &str = "unknown-source";
#[derive(Debug, Clone, Copy)]
pub struct OsvScanner;
impl Adapter for OsvScanner {
fn analyzer(&self) -> &'static str {
ANALYZER
}
fn summary(&self) -> &'static str {
"OSV.dev advisories against resolved lockfiles (Python, Java, Node, Rust dependencies)"
}
fn languages(&self) -> &'static [&'static str] {
&["python", "java", "javascript", "typescript", "rust"]
}
fn asset_ids(&self) -> &'static [&'static str] {
&[DB_ASSET]
}
fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
Invocation {
program: "osv-scanner".to_owned(),
args: vec![
"scan".to_owned(),
"source".to_owned(),
"--offline".to_owned(),
"--local-db-path".to_owned(),
assets.arg(DB_ASSET),
"--format".to_owned(),
"json".to_owned(),
"--recursive".to_owned(),
".".to_owned(),
],
success_statuses: vec![0, 1],
}
}
fn normalize(
&self,
native: &[u8],
ctx: &NativeContext<'_>,
) -> Result<NormalizedReport, ExecError> {
let output: ScanOutput = serde_json::from_slice(native)?;
let Some(results) = output.results else {
return Err(ExecError::MalformedReport(
"not an osv-scanner report: no `results` array".to_owned(),
));
};
let mut findings = Vec::new();
for result in &results {
let source = source_component(result.source.path.as_deref(), ctx.worktree);
for entry in &result.packages {
convert_package(entry, &source, &mut findings)?;
}
}
Ok(NormalizedReport {
schema: REPORT_SCHEMA.to_owned(),
analyzer: ANALYZER.to_owned(),
analyzer_version: ctx.version_or(None),
started_at: ctx.started_at.clone(),
ended_at: ctx.ended_at.clone(),
exit_status: ctx.exit_status,
rules_digest: None,
image_digest: None,
advisory_db: ctx.advisory_db.clone(),
source: ctx.source.clone(),
findings,
})
}
}
fn convert_package(
entry: &PackageEntry,
source: &str,
into: &mut Vec<ReportFinding>,
) -> Result<(), ExecError> {
let Some(package) = entry.package.as_ref() else {
return Err(ExecError::MalformedReport(
"an osv-scanner package entry has no `package` object".to_owned(),
));
};
if package.name.trim().is_empty() {
return Err(ExecError::MalformedReport(
"an osv-scanner package entry has an unnamed package".to_owned(),
));
}
let version = if package.version.trim().is_empty() {
"unknown-version"
} else {
package.version.trim()
};
let ecosystem = if package.ecosystem.trim().is_empty() {
"unknown-ecosystem"
} else {
package.ecosystem.trim()
};
for group in groups_of(entry) {
let mut ids: Vec<&str> = group
.ids
.iter()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
.collect();
ids.sort_unstable();
ids.dedup();
let Some(&rule) = ids.first() else {
return Err(ExecError::MalformedReport(
"an osv-scanner group names no advisory id".to_owned(),
));
};
let members: Vec<&Vulnerability> = entry
.vulnerabilities
.iter()
.filter(|v| ids.contains(&v.id.trim()))
.collect();
let aliases = alias_set(&group, &members);
let title = members
.iter()
.filter_map(|v| v.summary.as_deref())
.map(str::trim)
.find(|s| !s.is_empty())
.map_or_else(
|| format!("{} {version} is affected by {rule}", package.name),
str::to_owned,
);
let message = members
.iter()
.filter_map(|v| v.details.as_deref())
.map(str::trim)
.find(|s| !s.is_empty())
.unwrap_or_default()
.to_owned();
into.push(ReportFinding {
identity: vec![
rule.to_owned(),
ecosystem.to_owned(),
package.name.clone(),
version.to_owned(),
source.to_owned(),
],
rule: rule.to_owned(),
severity: severity(&members),
title,
message,
path: (source != UNKNOWN_SOURCE).then(|| source.to_owned()),
span: None,
meta: serde_json::json!({
"ecosystem": ecosystem,
"package": package.name,
"version": version,
"aliases": aliases,
"ids": ids,
"max_severity": group.max_severity,
"informational": informational(&members),
"cvss": cvss_vectors(&members),
"withdrawn": members.iter().find_map(|v| v.withdrawn.clone()),
"source": source,
}),
});
}
Ok(())
}
fn groups_of(entry: &PackageEntry) -> Vec<Group> {
if !entry.groups.is_empty() {
return entry.groups.clone();
}
entry
.vulnerabilities
.iter()
.map(|v| Group {
ids: vec![v.id.clone()],
aliases: v.aliases.clone(),
max_severity: String::new(),
})
.collect()
}
fn alias_set(group: &Group, members: &[&Vulnerability]) -> Vec<String> {
let mut all: Vec<String> = group
.ids
.iter()
.chain(group.aliases.iter())
.chain(members.iter().flat_map(|v| v.aliases.iter()))
.chain(members.iter().map(|v| &v.id))
.map(|id| id.trim().to_owned())
.filter(|id| !id.is_empty())
.collect();
all.sort();
all.dedup();
all
}
fn informational(members: &[&Vulnerability]) -> Option<String> {
members
.iter()
.flat_map(|v| v.affected.iter())
.filter_map(|a| a.database_specific.as_ref())
.filter_map(|d| d.informational.as_deref())
.map(str::trim)
.find(|k| !k.is_empty())
.map(str::to_owned)
}
fn cvss_vectors(members: &[&Vulnerability]) -> Vec<String> {
let mut out: Vec<String> = members
.iter()
.flat_map(|v| v.severity.iter())
.map(|s| s.score.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
out.sort();
out.dedup();
out
}
fn severity(members: &[&Vulnerability]) -> Severity {
let mut best: Option<Severity> = None;
for member in members {
for level in member_levels(member) {
if best.as_ref().is_none_or(|b| rank(&level) > rank(b)) {
best = Some(level);
}
}
}
best.unwrap_or(Severity::High)
}
fn member_levels(member: &Vulnerability) -> Vec<Severity> {
let mut levels = Vec::new();
if let Some(token) = member
.database_specific
.as_ref()
.and_then(|d| d.severity.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
{
levels.push(from_github(token));
}
for affected in &member.affected {
if let Some(kind) = affected
.database_specific
.as_ref()
.and_then(|d| d.informational.as_deref())
.map(str::trim)
.filter(|k| !k.is_empty())
{
levels.push(from_informational(kind));
}
}
levels
}
fn from_github(token: &str) -> Severity {
match token.to_ascii_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"moderate" | "medium" => Severity::Medium,
"low" => Severity::Low,
other => Severity::from_token(other),
}
}
fn from_informational(kind: &str) -> Severity {
match kind {
"unsound" => Severity::Medium,
"unmaintained" | "yanked" => Severity::Low,
"notice" => Severity::Info,
other => Severity::from_token(other),
}
}
fn rank(severity: &Severity) -> u8 {
match severity {
Severity::Critical => 5,
Severity::High => 4,
Severity::Medium => 3,
Severity::Low => 2,
Severity::Info => 1,
Severity::Other(_) => 0,
}
}
fn source_component(reported: Option<&str>, worktree: Option<&Path>) -> String {
let Some(reported) = reported.map(str::trim).filter(|p| !p.is_empty()) else {
return UNKNOWN_SOURCE.to_owned();
};
let path = Path::new(reported);
if path.is_relative() {
return normalise(reported);
}
let Some(worktree) = worktree else {
return UNKNOWN_SOURCE.to_owned();
};
let candidates = [
Some(worktree.to_path_buf()),
std::fs::canonicalize(worktree).ok(),
];
for candidate in candidates.into_iter().flatten() {
if let Ok(relative) = path.strip_prefix(&candidate) {
let relative = relative.to_string_lossy();
if !relative.is_empty() {
return normalise(&relative);
}
}
}
UNKNOWN_SOURCE.to_owned()
}
fn normalise(path: &str) -> String {
path.replace('\\', "/")
}
#[derive(Debug, Deserialize)]
struct ScanOutput {
#[serde(default)]
results: Option<Vec<ScanResult>>,
}
#[derive(Debug, Deserialize)]
struct ScanResult {
#[serde(default)]
source: Source,
#[serde(default)]
packages: Vec<PackageEntry>,
}
#[derive(Debug, Default, Deserialize)]
struct Source {
#[serde(default)]
path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PackageEntry {
#[serde(default)]
package: Option<PackageId>,
#[serde(default)]
vulnerabilities: Vec<Vulnerability>,
#[serde(default)]
groups: Vec<Group>,
}
#[derive(Debug, Deserialize)]
struct PackageId {
#[serde(default)]
name: String,
#[serde(default)]
version: String,
#[serde(default)]
ecosystem: String,
}
#[derive(Debug, Clone, Deserialize)]
struct Group {
#[serde(default)]
ids: Vec<String>,
#[serde(default)]
aliases: Vec<String>,
#[serde(default)]
max_severity: String,
}
#[derive(Debug, Deserialize)]
struct Vulnerability {
#[serde(default)]
id: String,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
details: Option<String>,
#[serde(default)]
aliases: Vec<String>,
#[serde(default)]
withdrawn: Option<String>,
#[serde(default)]
severity: Vec<SeverityScore>,
#[serde(default)]
database_specific: Option<DatabaseSpecific>,
#[serde(default)]
affected: Vec<Affected>,
}
#[derive(Debug, Deserialize)]
struct SeverityScore {
#[serde(default)]
score: String,
}
#[derive(Debug, Deserialize)]
struct DatabaseSpecific {
#[serde(default)]
severity: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Affected {
#[serde(default)]
database_specific: Option<AffectedDatabaseSpecific>,
}
#[derive(Debug, Deserialize)]
struct AffectedDatabaseSpecific {
#[serde(default)]
informational: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{ANALYZER, DB_ASSET, OsvScanner, UNKNOWN_SOURCE, source_component};
use crate::adapter::{Adapter, AssetPaths, NativeContext};
use crate::runner::ExecError;
use rto_graph::{Severity, SourceIdentity};
static SOURCE: std::sync::LazyLock<SourceIdentity> =
std::sync::LazyLock::new(SourceIdentity::default);
fn ctx(worktree: Option<&'static str>) -> NativeContext<'static> {
NativeContext {
started_at: "2026-08-16T09:00:00Z".to_owned(),
ended_at: "2026-08-16T09:00:06Z".to_owned(),
analyzer_version: Some("2.5.0".to_owned()),
exit_status: 1,
source: &SOURCE,
rules_digest: None,
advisory_db: None,
worktree: worktree.map(std::path::Path::new),
snippets: &crate::snippet::NoSnippets,
}
}
const NATIVE: &str = r#"{
"results": [
{
"source": {"path": "/repo/Cargo.lock", "type": "lockfile"},
"packages": [
{
"package": {"name": "openssl", "version": "0.10.55", "ecosystem": "crates.io"},
"vulnerabilities": [
{
"id": "RUSTSEC-2023-0072",
"summary": "`openssl` `X509StoreRef::objects` is unsound",
"details": "The objects method is unsound.",
"aliases": ["GHSA-xphf-cx8h-7q9g"],
"database_specific": {"license": "CC0-1.0"},
"affected": [{"database_specific": {"informational": "unsound", "cvss": null}}]
},
{
"id": "GHSA-xphf-cx8h-7q9g",
"summary": "`openssl` `X509StoreRef::objects` is unsound",
"aliases": ["RUSTSEC-2023-0072"],
"database_specific": {"severity": "MODERATE"},
"affected": [{"database_specific": {}}]
}
],
"groups": [
{
"ids": ["RUSTSEC-2023-0072", "GHSA-xphf-cx8h-7q9g"],
"aliases": ["GHSA-xphf-cx8h-7q9g", "RUSTSEC-2023-0072"],
"max_severity": ""
}
]
},
{
"package": {"name": "derivative", "version": "2.2.0", "ecosystem": "crates.io"},
"vulnerabilities": [
{
"id": "RUSTSEC-2024-0388",
"summary": "`derivative` is unmaintained; consider using an alternative",
"database_specific": {"license": "CC0-1.0"},
"affected": [{"database_specific": {"informational": "unmaintained"}}]
}
],
"groups": [
{"ids": ["RUSTSEC-2024-0388"], "aliases": ["RUSTSEC-2024-0388"], "max_severity": ""}
]
}
]
},
{
"source": {"path": "/repo/app/package-lock.json", "type": "lockfile"},
"packages": [
{
"package": {"name": "lodash", "version": "4.17.15", "ecosystem": "npm"},
"vulnerabilities": [
{
"id": "GHSA-p6mc-m468-83gw",
"summary": "Prototype Pollution in lodash",
"details": "Versions prior to 4.17.21 are vulnerable.",
"aliases": ["CVE-2020-8203"],
"severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N"}],
"database_specific": {"severity": "HIGH"},
"affected": [{"database_specific": {}}]
}
],
"groups": [
{
"ids": ["GHSA-p6mc-m468-83gw"],
"aliases": ["CVE-2020-8203", "GHSA-p6mc-m468-83gw"],
"max_severity": "7.4"
}
]
}
]
}
]
}"#;
#[test]
fn a_duplicated_advisory_becomes_one_finding_not_two() {
let report = OsvScanner
.normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
assert_eq!(report.analyzer, ANALYZER);
assert_eq!(report.findings.len(), 3, "openssl, derivative, lodash");
let openssl: Vec<_> = report
.findings
.iter()
.filter(|f| f.meta["package"] == "openssl")
.collect();
assert_eq!(openssl.len(), 1);
let aliases = openssl[0].meta["aliases"].as_array().expect("aliases");
assert!(aliases.iter().any(|a| a == "RUSTSEC-2023-0072"));
assert!(aliases.iter().any(|a| a == "GHSA-xphf-cx8h-7q9g"));
}
#[test]
fn uses_the_advisory_ecosystem_package_version_manifest_identity() {
let report = OsvScanner
.normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
let lodash = report
.findings
.iter()
.find(|f| f.meta["package"] == "lodash")
.expect("lodash");
assert_eq!(
lodash.identity,
vec![
"GHSA-p6mc-m468-83gw",
"npm",
"lodash",
"4.17.15",
"app/package-lock.json"
]
);
assert_eq!(lodash.path.as_deref(), Some("app/package-lock.json"));
assert_eq!(lodash.severity, Severity::High);
assert_eq!(lodash.meta["max_severity"], "7.4");
}
#[test]
fn an_absolute_reported_path_is_made_worktree_relative() {
let report = OsvScanner
.normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
for finding in &report.findings {
let path = finding.path.as_deref().expect("a path");
assert!(!path.starts_with('/'), "{path} is still absolute");
crate::runner::check_reported_path(path).expect("must pass the preflight");
}
}
#[test]
fn a_path_outside_the_worktree_is_named_not_guessed() {
let report = OsvScanner
.normalize(NATIVE.as_bytes(), &ctx(Some("/elsewhere")))
.expect("parse");
assert!(report.findings.iter().all(|f| f.path.is_none()));
assert!(
report
.findings
.iter()
.all(|f| f.identity[4] == UNKNOWN_SOURCE)
);
}
#[test]
fn source_components_cover_relative_absolute_and_unknown() {
let repo = std::path::Path::new("/repo");
assert_eq!(
source_component(Some("/repo/a/Cargo.lock"), Some(repo)),
"a/Cargo.lock"
);
assert_eq!(
source_component(Some("a/Cargo.lock"), Some(repo)),
"a/Cargo.lock"
);
assert_eq!(
source_component(Some("/other/Cargo.lock"), Some(repo)),
UNKNOWN_SOURCE
);
assert_eq!(source_component(Some("/repo/x"), None), UNKNOWN_SOURCE);
assert_eq!(source_component(None, Some(repo)), UNKNOWN_SOURCE);
assert_eq!(source_component(Some(" "), Some(repo)), UNKNOWN_SOURCE);
}
#[test]
fn informational_kinds_are_graded_the_way_cargo_audit_grades_them() {
let report = OsvScanner
.normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
let unsound = report
.findings
.iter()
.find(|f| f.meta["package"] == "openssl")
.expect("openssl");
assert_eq!(unsound.severity, Severity::Medium);
assert_eq!(unsound.meta["informational"], "unsound");
let unmaintained = report
.findings
.iter()
.find(|f| f.meta["package"] == "derivative")
.expect("derivative");
assert_eq!(unmaintained.severity, Severity::Low);
assert_eq!(unmaintained.meta["informational"], "unmaintained");
}
#[test]
fn an_advisory_with_no_published_level_is_graded_high() {
let native = r#"{"results":[{"source":{"path":"/repo/Cargo.lock"},"packages":[
{"package":{"name":"x","version":"1.0.0","ecosystem":"crates.io"},
"vulnerabilities":[{"id":"OSV-1","summary":"bad"}],
"groups":[{"ids":["OSV-1"],"aliases":[],"max_severity":""}]}]}]}"#;
let report = OsvScanner
.normalize(native.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
assert_eq!(report.findings[0].severity, Severity::High);
}
#[test]
fn a_group_takes_the_highest_level_its_records_publish() {
let native = r#"{"results":[{"source":{"path":"/repo/p.json"},"packages":[
{"package":{"name":"x","version":"1.0.0","ecosystem":"npm"},
"vulnerabilities":[
{"id":"A-1","summary":"a","database_specific":{"severity":"LOW"}},
{"id":"B-1","summary":"b","database_specific":{"severity":"CRITICAL"}}],
"groups":[{"ids":["A-1","B-1"],"aliases":[],"max_severity":"9.8"}]}]}]}"#;
let report = OsvScanner
.normalize(native.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].severity, Severity::Critical);
}
#[test]
fn the_same_advisory_in_two_manifests_is_two_findings() {
let native = r#"{"results":[
{"source":{"path":"/repo/a/package-lock.json"},"packages":[
{"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
"vulnerabilities":[{"id":"G-1","summary":"pollution"}],
"groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]},
{"source":{"path":"/repo/b/package-lock.json"},"packages":[
{"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
"vulnerabilities":[{"id":"G-1","summary":"pollution"}],
"groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]}]}"#;
let report = OsvScanner
.normalize(native.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
assert_eq!(report.findings.len(), 2);
assert_ne!(report.findings[0].identity, report.findings[1].identity);
}
#[test]
fn vulnerabilities_without_groups_are_still_reported() {
let native = r#"{"results":[{"source":{"path":"/repo/req.txt"},"packages":[
{"package":{"name":"django","version":"2.2.0","ecosystem":"PyPI"},
"vulnerabilities":[{"id":"PYSEC-2019-10","summary":"sql injection"}]}]}]}"#;
let report = OsvScanner
.normalize(native.as_bytes(), &ctx(Some("/repo")))
.expect("parse");
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, "PYSEC-2019-10");
}
#[test]
fn a_clean_scan_is_a_valid_empty_report() {
let report = OsvScanner
.normalize(br#"{"results":[]}"#, &ctx(Some("/repo")))
.expect("parse");
assert!(report.findings.is_empty());
}
#[test]
fn refuses_output_that_is_not_an_osv_scanner_report() {
let err = OsvScanner
.normalize(br#"{"experimental_config":{}}"#, &ctx(Some("/repo")))
.expect_err("must be refused");
assert!(matches!(err, ExecError::MalformedReport(_)));
assert!(err.to_string().contains("no `results` array"), "{err}");
assert!(matches!(
OsvScanner.normalize(b"<html>", &ctx(Some("/repo"))),
Err(ExecError::Json(_))
));
}
#[test]
fn refuses_a_package_entry_with_no_package() {
let native = r#"{"results":[{"source":{"path":"/repo/x"},"packages":[
{"vulnerabilities":[{"id":"A"}]}]}]}"#;
assert!(matches!(
OsvScanner.normalize(native.as_bytes(), &ctx(Some("/repo"))),
Err(ExecError::MalformedReport(_))
));
}
#[test]
fn the_invocation_pins_the_database_and_really_goes_offline() {
let entries = [(DB_ASSET, std::path::PathBuf::from("/cache/osv"))];
let invocation = OsvScanner.command(&AssetPaths::new(&entries));
assert_eq!(invocation.program, "osv-scanner");
assert_eq!(invocation.args[0], "scan");
assert_eq!(invocation.args[1], "source");
assert!(invocation.args.contains(&"--offline".to_owned()));
assert!(
!invocation
.args
.contains(&"--offline-vulnerabilities".to_owned()),
"that flag alone consults nothing and reports a clean scan"
);
let db = invocation
.args
.iter()
.position(|a| a == "--local-db-path")
.map(|i| invocation.args[i + 1].clone())
.expect("a --local-db-path argument");
assert_eq!(db, "/cache/osv");
assert_eq!(invocation.args.last().map(String::as_str), Some("."));
assert_eq!(invocation.success_statuses, vec![0, 1]);
}
#[test]
fn covers_the_dependency_axis_for_the_ecosystems_it_provisions() {
assert!(OsvScanner.languages().contains(&"python"));
assert!(OsvScanner.languages().contains(&"java"));
assert!(OsvScanner.languages().contains(&"javascript"));
assert_eq!(OsvScanner.asset_ids(), &[DB_ASSET]);
}
}