use crate::types::{YaraRuleMetadata, YaraScanResult};
use reqwest::Client;
use serde::Deserialize;
use std::time::Duration;
use tracing::{debug, warn};
const OSV_QUERY_URL: &str = "https://api.osv.dev/v1/query";
const OSV_QUERY_TIMEOUT_SECS: u64 = 5;
#[derive(Debug, Clone)]
pub struct PackageSpec {
pub ecosystem: &'static str,
pub name: String,
pub version: Option<String>,
}
pub fn parse_package_spec_from_command(command: &str, args: &[String]) -> Option<PackageSpec> {
match command {
"npx" => parse_npx(args),
"uvx" => parse_uvx(args),
_ => None,
}
}
fn parse_npx(args: &[String]) -> Option<PackageSpec> {
let pkg_arg = args.iter().find(|a| !a.starts_with('-'))?;
let (name, version) = split_npm_spec(pkg_arg)?;
Some(PackageSpec {
ecosystem: "npm",
name,
version,
})
}
fn split_npm_spec(raw: &str) -> Option<(String, Option<String>)> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Some(stripped) = raw.strip_prefix('@') {
let (scope, rest) = stripped.split_once('/')?;
if let Some((pkg, ver)) = rest.split_once('@') {
return Some((format!("@{scope}/{pkg}"), version_or_none(ver)));
}
return Some((format!("@{scope}/{rest}"), None));
}
if let Some((name, ver)) = raw.split_once('@') {
return Some((name.to_string(), version_or_none(ver)));
}
Some((raw.to_string(), None))
}
fn parse_uvx(args: &[String]) -> Option<PackageSpec> {
let mut iter = args.iter().peekable();
while let Some(arg) = iter.peek() {
if *arg == "--from" {
iter.next();
if let Some(spec) = iter.next() {
let (name, version) = split_pypi_spec(spec)?;
return Some(PackageSpec {
ecosystem: "PyPI",
name,
version,
});
}
return None;
}
if arg.starts_with('-') {
iter.next();
continue;
}
break;
}
let pkg = iter.next()?;
let (name, version) = split_pypi_spec(pkg)?;
Some(PackageSpec {
ecosystem: "PyPI",
name,
version,
})
}
fn split_pypi_spec(raw: &str) -> Option<(String, Option<String>)> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Some((name, ver)) = raw.split_once("==") {
return Some((name.to_string(), version_or_none(ver)));
}
Some((raw.to_string(), None))
}
fn version_or_none(s: &str) -> Option<String> {
let s = s.trim();
if s.is_empty() || s.eq_ignore_ascii_case("latest") {
None
} else {
Some(s.to_string())
}
}
#[derive(Debug, Deserialize)]
struct OsvQueryResponse {
#[serde(default)]
vulns: Vec<OsvVulnerability>,
}
#[derive(Debug, Deserialize)]
struct OsvVulnerability {
id: String,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
details: Option<String>,
#[serde(default)]
severity: Vec<OsvSeverity>,
#[serde(default)]
aliases: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OsvSeverity {
#[serde(rename = "type")]
severity_type: String,
score: String,
}
pub async fn query_osv(client: Client, spec: PackageSpec) -> Vec<YaraScanResult> {
debug!(
"Querying OSV.dev for {}/{} ({:?})",
spec.ecosystem, spec.name, spec.version
);
let request_body = build_osv_request(&spec);
let response = match client
.post(OSV_QUERY_URL)
.timeout(Duration::from_secs(OSV_QUERY_TIMEOUT_SECS))
.json(&request_body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(
"OSV query failed for {}/{}: {}",
spec.ecosystem, spec.name, e
);
return Vec::new();
}
};
if !response.status().is_success() {
warn!(
"OSV returned {} for {}/{}",
response.status(),
spec.ecosystem,
spec.name
);
return Vec::new();
}
let body: OsvQueryResponse = match response.json().await {
Ok(b) => b,
Err(e) => {
warn!(
"Failed to parse OSV response for {}/{}: {}",
spec.ecosystem, spec.name, e
);
return Vec::new();
}
};
body.vulns
.into_iter()
.map(|v| osv_finding_to_yara_result(&spec, v))
.collect()
}
fn build_osv_request(spec: &PackageSpec) -> serde_json::Value {
let mut req = serde_json::json!({
"package": {
"name": spec.name,
"ecosystem": spec.ecosystem,
}
});
if let Some(version) = &spec.version {
req["version"] = serde_json::Value::String(version.clone());
}
req
}
fn osv_finding_to_yara_result(spec: &PackageSpec, vuln: OsvVulnerability) -> YaraScanResult {
let cvss = vuln
.severity
.iter()
.find(|s| s.severity_type.starts_with("CVSS"))
.map(|s| s.score.as_str())
.unwrap_or("unknown");
let severity = severity_label_for_cvss(cvss);
let summary = vuln
.summary
.as_deref()
.or(vuln.details.as_deref())
.unwrap_or("No summary provided by OSV");
let aliases = if vuln.aliases.is_empty() {
String::new()
} else {
format!(" (aliases: {})", vuln.aliases.join(", "))
};
let context = format!(
"{}/{} {}: {}{aliases}",
spec.ecosystem,
spec.name,
spec.version.as_deref().unwrap_or("(any version)"),
summary
);
YaraScanResult {
target_type: "dependency".to_string(),
target_name: format!(
"{}/{}@{}",
spec.ecosystem,
spec.name,
spec.version.as_deref().unwrap_or("?")
),
rule_name: "VulnerableDependency".to_string(),
rule_file: Some("osv".to_string()),
matched_text: Some(vuln.id.clone()),
context,
rule_metadata: Some(YaraRuleMetadata {
name: Some("Vulnerable Dependency".to_string()),
author: Some("OSV.dev".to_string()),
date: None,
version: None,
description: Some(summary.to_string()),
severity: Some(severity.to_string()),
category: Some("supply-chain".to_string()),
confidence: Some("HIGH".to_string()),
tags: vec!["dependency".to_string(), "osv".to_string()],
}),
owasp_tags: crate::taxonomy::tags_for_yara_rule("VulnerableDependency"),
phase: Some("pre-scan".to_string()),
rules_executed: None,
security_issues_detected: None,
total_items_scanned: None,
total_matches: None,
status: Some("warning".to_string()),
}
}
fn severity_label_for_cvss(score_text: &str) -> &'static str {
let numeric = score_text
.split('/')
.find_map(|part| part.parse::<f32>().ok());
match numeric {
Some(n) if n >= 9.0 => "CRITICAL",
Some(n) if n >= 7.0 => "HIGH",
Some(n) if n >= 4.0 => "MEDIUM",
Some(_) => "LOW",
None => "MEDIUM",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_npx_pkg() {
let spec = parse_package_spec_from_command(
"npx",
&[
"-y".into(),
"@modelcontextprotocol/server-everything".into(),
],
)
.expect("should parse");
assert_eq!(spec.ecosystem, "npm");
assert_eq!(spec.name, "@modelcontextprotocol/server-everything");
assert_eq!(spec.version, None);
}
#[test]
fn parses_npx_with_pinned_version() {
let spec = parse_package_spec_from_command("npx", &["lodash@4.17.21".into()])
.expect("should parse");
assert_eq!(spec.name, "lodash");
assert_eq!(spec.version, Some("4.17.21".into()));
}
#[test]
fn parses_npx_scoped_with_version() {
let spec = parse_package_spec_from_command("npx", &["@scope/pkg@1.2.3".into()])
.expect("should parse");
assert_eq!(spec.name, "@scope/pkg");
assert_eq!(spec.version, Some("1.2.3".into()));
}
#[test]
fn drops_latest_tag() {
let spec = parse_package_spec_from_command("npx", &["lodash@latest".into()])
.expect("should parse");
assert_eq!(spec.version, None);
}
#[test]
fn parses_uvx_with_from_flag() {
let spec = parse_package_spec_from_command(
"uvx",
&["--from".into(), "ruff==0.8.4".into(), "ruff".into()],
)
.expect("should parse");
assert_eq!(spec.ecosystem, "PyPI");
assert_eq!(spec.name, "ruff");
assert_eq!(spec.version, Some("0.8.4".into()));
}
#[test]
fn parses_uvx_positional() {
let spec = parse_package_spec_from_command("uvx", &["black".into()]).expect("should parse");
assert_eq!(spec.ecosystem, "PyPI");
assert_eq!(spec.name, "black");
}
#[test]
fn rejects_unrecognized_runner() {
assert!(parse_package_spec_from_command("python3", &["script.py".into()]).is_none());
assert!(
parse_package_spec_from_command("docker", &["run".into(), "image".into()]).is_none()
);
}
#[test]
fn cvss_severity_buckets() {
assert_eq!(severity_label_for_cvss("9.8"), "CRITICAL");
assert_eq!(severity_label_for_cvss("7.5"), "HIGH");
assert_eq!(severity_label_for_cvss("5.0"), "MEDIUM");
assert_eq!(severity_label_for_cvss("3.1"), "LOW");
assert_eq!(
severity_label_for_cvss("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"),
"MEDIUM"
);
assert_eq!(severity_label_for_cvss(""), "MEDIUM");
}
}