//! Security advisories: the OSV records read from a dump, the version ranges they name, and the //! lockfile parsers that turn a repo's `Cargo.lock`, `package-lock.json` and `go.sum` into //! dependencies. Storage is in `store/advisories.rs`; the download and the scan are in forge-web. //! //! Only the OSV fields the forge needs are read: `id`, `summary`, `aliases`, `severity`, //! `affected[].package`, `affected[].ranges` (SEMVER and ECOSYSTEM events) and //! `affected[].versions`. Git ranges are skipped. Versions are compared as semver: a leading `v` //! is dropped first, so Go versions and Go pseudo-versions compare as semver too. use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Seek}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::error::{Error, Result}; /// The OSV ecosystem names the forge knows how to scan. `[advisories] ecosystems` picks a subset. pub const KNOWN_ECOSYSTEMS: [&str; 3] = ["crates.io", "npm", "Go"]; /// Largest single OSV record read from a dump. Real records are a few KiB. const MAX_RECORD_BYTES: u64 = 4 * 1024 * 1024; /// One event of a range, in the order it appears in the record. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Event { Introduced(String), Fixed(String), LastAffected(String), Limit(String), } /// One SEMVER or ECOSYSTEM range of an affected package. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Range { pub events: Vec, } /// An advisory as it applies to one package in one ecosystem. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Advisory { pub id: String, pub ecosystem: String, pub package: String, pub summary: String, pub severity: String, pub modified: String, #[serde(default)] pub aliases: Vec, pub ranges: Vec, pub versions: Vec, } /// A package version read from a lockfile. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Dependency { pub ecosystem: String, pub package: String, pub version: String, pub manifest_path: String, } #[derive(Deserialize)] struct Record { id: String, #[serde(default)] summary: String, #[serde(default)] aliases: Vec, #[serde(default)] modified: String, #[serde(default)] severity: Vec, #[serde(default)] database_specific: Value, #[serde(default)] affected: Vec, } #[derive(Deserialize)] struct SeverityEntry { #[serde(default)] score: String, } #[derive(Deserialize)] struct Affected { #[serde(default)] package: Option, #[serde(default)] ranges: Vec, #[serde(default)] versions: Vec, } #[derive(Deserialize)] struct PackageRef { #[serde(default)] ecosystem: String, #[serde(default)] name: String, } #[derive(Deserialize)] struct RawRange { #[serde(rename = "type", default)] kind: String, #[serde(default)] events: Vec, } #[derive(Deserialize)] struct RawEvent { introduced: Option, fixed: Option, last_affected: Option, limit: Option, } /// Parses one OSV record into one [`Advisory`] per affected package of `ecosystem`. Several /// `affected` entries for the same package are merged into one advisory. pub fn parse_record(json: &[u8], ecosystem: &str) -> Result> { let record: Record = serde_json::from_slice(json) .map_err(|e| Error::Invalid(format!("advisory record is not valid OSV: {e}")))?; if record.id.is_empty() { return Err(Error::Invalid("advisory record has no id".into())); } let severity = record .database_specific .get("severity") .and_then(Value::as_str) .map(str::to_owned) .or_else(|| record.severity.first().map(|s| s.score.clone())) .unwrap_or_default(); let mut merged: BTreeMap = BTreeMap::new(); for affected in record.affected { let Some(package) = affected.package else { continue; }; if package.ecosystem != ecosystem || package.name.is_empty() { continue; } let entry = merged .entry(package.name.clone()) .or_insert_with(|| Advisory { id: record.id.clone(), ecosystem: ecosystem.to_owned(), package: package.name.clone(), summary: record.summary.clone(), severity: severity.clone(), modified: record.modified.clone(), aliases: record.aliases.clone(), ranges: Vec::new(), versions: Vec::new(), }); for raw in affected.ranges { if !raw.kind.eq_ignore_ascii_case("SEMVER") && !raw.kind.eq_ignore_ascii_case("ECOSYSTEM") { continue; } let events = raw .events .into_iter() .filter_map(|e| { if let Some(v) = e.introduced { Some(Event::Introduced(v)) } else if let Some(v) = e.fixed { Some(Event::Fixed(v)) } else if let Some(v) = e.last_affected { Some(Event::LastAffected(v)) } else { e.limit.map(Event::Limit) } }) .collect(); entry.ranges.push(Range { events }); } entry.versions.extend(affected.versions); } Ok(merged.into_values().collect()) } /// Reads every `.json` record of an OSV zip dump for `ecosystem`. A record that does not parse, /// or is too large, is skipped and counted in the second value. pub fn read_dump(reader: R, ecosystem: &str) -> Result<(Vec, usize)> { let mut zip = zip::ZipArchive::new(reader) .map_err(|e| Error::Invalid(format!("advisory dump is not a zip file: {e}")))?; let mut advisories = Vec::new(); let mut skipped = 0; for index in 0..zip.len() { let mut file = zip .by_index(index) .map_err(|e| Error::Invalid(format!("advisory dump entry unreadable: {e}")))?; if file.is_dir() || !file.name().is_ok_and(|name| name.ends_with(".json")) { continue; } let mut buf = Vec::new(); file.by_ref() .take(MAX_RECORD_BYTES + 1) .read_to_end(&mut buf)?; if buf.len() as u64 > MAX_RECORD_BYTES { skipped += 1; continue; } match parse_record(&buf, ecosystem) { Ok(found) => advisories.extend(found), Err(_) => skipped += 1, } } Ok((advisories, skipped)) } /// Parses a version as semver, after dropping a leading `v` (Go style). pub fn parse_version(raw: &str) -> Option { semver::Version::parse(raw.trim().trim_start_matches('v')).ok() } /// Whether `advisory` covers `version` of its package. A version listed in `versions` matches by /// text. Otherwise the version must fall inside one of the ranges. pub fn affects(advisory: &Advisory, version: &str) -> bool { let wanted = version.trim().trim_start_matches('v'); if advisory .versions .iter() .any(|v| v.trim().trim_start_matches('v') == wanted) { return true; } let Some(parsed) = parse_version(version) else { return false; }; advisory .ranges .iter() .any(|range| range_affects(range, &parsed)) } /// Walks the events of one range. An interval runs from an `introduced` event to the next /// `fixed` (exclusive) or `last_affected` (inclusive). `introduced` of `0` starts at the beginning. /// An interval still open at the end runs forever. fn range_affects(range: &Range, version: &semver::Version) -> bool { // `Some(start)` while an interval is open; `start` is `None` when it began at `0`. let mut open: Option> = None; for event in &range.events { match event { Event::Introduced(raw) => { open = Some(if raw == "0" { None } else { // An unreadable start is treated as "from the beginning": more alerts, never fewer. parse_version(raw) }); } Event::Fixed(raw) => { if let Some(start) = open.take() && starts_before(&start, version) && parse_version(raw).is_some_and(|fixed| *version < fixed) { return true; } } Event::LastAffected(raw) => { if let Some(start) = open.take() && starts_before(&start, version) && parse_version(raw).is_some_and(|last| *version <= last) { return true; } } Event::Limit(_) => {} } } open.is_some_and(|start| starts_before(&start, version)) } fn starts_before(start: &Option, version: &semver::Version) -> bool { start.as_ref().is_none_or(|s| version >= s) } /// Reads `Cargo.lock`. Only registry packages count: path and git packages have no `source` /// from crates.io and are skipped. pub fn parse_cargo_lock(text: &str, manifest_path: &str) -> Result> { #[derive(Deserialize)] struct Lock { #[serde(default)] package: Vec, } #[derive(Deserialize)] struct Package { name: String, version: String, #[serde(default)] source: Option, } let lock: Lock = toml::from_str(text) .map_err(|e| Error::Invalid(format!("Cargo.lock is not valid TOML: {e}")))?; Ok(lock .package .into_iter() .filter(|p| { p.source .as_deref() .is_some_and(|s| s.starts_with("registry+")) }) .map(|p| dependency("crates.io", p.name, p.version, manifest_path)) .collect()) } /// Reads `package-lock.json` in the v2 and v3 `packages` layout. Entries without a version, the /// root entry and linked workspace packages are skipped. pub fn parse_package_lock(text: &str, manifest_path: &str) -> Result> { let doc: Value = serde_json::from_str(text) .map_err(|e| Error::Invalid(format!("package-lock.json is not valid JSON: {e}")))?; let Some(packages) = doc.get("packages").and_then(Value::as_object) else { return Ok(Vec::new()); }; let mut out = Vec::new(); for (key, entry) in packages { if key.is_empty() || entry.get("link").and_then(Value::as_bool) == Some(true) { continue; } let Some(version) = entry.get("version").and_then(Value::as_str) else { continue; }; // `node_modules/a/node_modules/@s/b` names `@s/b`: the part after the last `node_modules/`. let name = match key.rsplit_once("node_modules/") { Some((_, name)) => name, None => continue, }; out.push(dependency( "npm", name.to_owned(), version.to_owned(), manifest_path, )); } Ok(out) } /// Reads `go.sum`. Each module version is listed once, with or without its `/go.mod` hash line; /// the `/go.mod` lines alone are not dependencies, so they are dropped. pub fn parse_go_sum(text: &str, manifest_path: &str) -> Vec { let mut seen = BTreeSet::new(); for line in text.lines() { let mut fields = line.split_whitespace(); let (Some(module), Some(version)) = (fields.next(), fields.next()) else { continue; }; if version.ends_with("/go.mod") { continue; } seen.insert((module.to_owned(), version.to_owned())); } seen.into_iter() .map(|(module, version)| dependency("Go", module, version, manifest_path)) .collect() } fn dependency( ecosystem: &str, package: String, version: String, manifest_path: &str, ) -> Dependency { Dependency { ecosystem: ecosystem.to_owned(), package, version, manifest_path: manifest_path.to_owned(), } } #[cfg(test)] mod tests { use super::*; fn adv(ranges: Vec, versions: Vec<&str>) -> Advisory { Advisory { id: "GHSA-test".into(), ecosystem: "crates.io".into(), package: "demo".into(), summary: String::new(), severity: String::new(), modified: String::new(), aliases: Vec::new(), ranges, versions: versions.into_iter().map(str::to_owned).collect(), } } fn range(events: Vec) -> Range { Range { events } } #[test] fn introduced_and_fixed_is_half_open() { let a = adv( vec![range(vec![ Event::Introduced("1.0.0".into()), Event::Fixed("1.2.0".into()), ])], vec![], ); assert!(!affects(&a, "0.9.9")); assert!(affects(&a, "1.0.0")); assert!(affects(&a, "1.1.5")); assert!(!affects(&a, "1.2.0")); assert!(!affects(&a, "2.0.0")); } #[test] fn introduced_zero_with_last_affected_is_inclusive() { let a = adv( vec![range(vec![ Event::Introduced("0".into()), Event::LastAffected("0.4.7".into()), ])], vec![], ); assert!(affects(&a, "0.0.1")); assert!(affects(&a, "0.4.7")); assert!(!affects(&a, "0.4.8")); } #[test] fn open_interval_runs_forever_and_later_intervals_work() { let a = adv( vec![range(vec![ Event::Introduced("1.0.0".into()), Event::Fixed("1.1.0".into()), Event::Introduced("2.0.0".into()), ])], vec![], ); assert!(affects(&a, "1.0.5")); assert!(!affects(&a, "1.5.0")); assert!(affects(&a, "9.9.9")); } #[test] fn go_v_prefix_and_pseudo_versions_compare_as_semver() { let a = adv( vec![range(vec![ Event::Introduced("0".into()), Event::Fixed("1.9.1".into()), ])], vec![], ); assert!(affects(&a, "v1.9.0")); assert!(!affects(&a, "v1.9.1")); assert!(affects(&a, "v0.0.0-20210101000000-abcdef123456")); } #[test] fn listed_versions_match_by_text() { let a = adv(vec![], vec!["1.0.3", "v2.0.0"]); assert!(affects(&a, "1.0.3")); assert!(affects(&a, "2.0.0")); assert!(!affects(&a, "1.0.4")); } #[test] fn unparsable_version_matches_nothing_by_range() { let a = adv(vec![range(vec![Event::Introduced("0".into())])], vec![]); assert!(!affects(&a, "not-a-version")); } #[test] fn parses_an_osv_record_and_merges_entries_for_one_package() { let json = br#"{ "id": "GHSA-xxxx", "summary": "bad things", "modified": "2026-01-01T00:00:00Z", "database_specific": {"severity": "HIGH"}, "affected": [ {"package": {"ecosystem": "crates.io", "name": "demo"}, "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.1"}]}]}, {"package": {"ecosystem": "crates.io", "name": "demo"}, "ranges": [{"type": "GIT", "events": [{"introduced": "0"}]}, {"type": "ECOSYSTEM", "events": [{"introduced": "3.0.0"}, {"last_affected": "3.0.5"}]}], "versions": ["0.9.0"]}, {"package": {"ecosystem": "npm", "name": "other"}, "ranges": []} ] }"#; let found = parse_record(json, "crates.io").expect("record parses"); assert_eq!(found.len(), 1); let a = &found[0]; assert_eq!(a.id, "GHSA-xxxx"); assert_eq!(a.severity, "HIGH"); assert_eq!(a.ranges.len(), 2, "the GIT range is skipped"); assert!(affects(a, "0.5.0")); assert!(!affects(a, "1.0.1")); assert!(affects(a, "3.0.2")); assert!(affects(a, "0.9.0")); } #[test] fn record_without_an_id_is_refused() { assert!(parse_record(br#"{"affected": []}"#, "npm").is_err()); assert!(parse_record(b"not json", "npm").is_err()); } #[test] fn reads_a_zip_dump_and_skips_bad_records() { use std::io::{Cursor, Write}; let mut buf = Cursor::new(Vec::new()); { let mut zip = zip::ZipWriter::new(&mut buf); let opts = zip::write::SimpleFileOptions::default(); zip.start_file("GHSA-a.json", opts).expect("start"); zip.write_all( br#"{"id":"GHSA-a","affected":[{"package":{"ecosystem":"npm","name":"left"}, "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}]}]}"#, ) .expect("write"); zip.start_file("broken.json", opts).expect("start"); zip.write_all(b"{ nope").expect("write"); zip.start_file("readme.txt", opts).expect("start"); zip.write_all(b"ignored").expect("write"); zip.finish().expect("finish"); } buf.set_position(0); let (found, skipped) = read_dump(buf, "npm").expect("dump reads"); assert_eq!(skipped, 1); assert_eq!(found.len(), 1); assert_eq!(found[0].package, "left"); } #[test] fn parses_cargo_lock_registry_packages_only() { let text = r#" version = 4 [[package]] name = "serde" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc" [[package]] name = "local" version = "0.1.0" "#; let deps = parse_cargo_lock(text, "Cargo.lock").expect("parses"); assert_eq!(deps.len(), 1); assert_eq!(deps[0].ecosystem, "crates.io"); assert_eq!(deps[0].package, "serde"); assert_eq!(deps[0].version, "1.0.0"); assert_eq!(deps[0].manifest_path, "Cargo.lock"); } #[test] fn parses_package_lock_v3_packages() { let text = r#"{ "lockfileVersion": 3, "packages": { "": {"name": "app", "version": "1.0.0"}, "node_modules/left-pad": {"version": "1.3.0"}, "node_modules/a/node_modules/@scope/inner": {"version": "2.0.1"}, "node_modules/linked": {"link": true}, "packages/ws": {"version": "0.0.1"} } }"#; let mut deps = parse_package_lock(text, "web/package-lock.json").expect("parses"); deps.sort(); let names: Vec<(&str, &str)> = deps .iter() .map(|d| (d.package.as_str(), d.version.as_str())) .collect(); assert_eq!( names, vec![("@scope/inner", "2.0.1"), ("left-pad", "1.3.0")] ); assert!(deps.iter().all(|d| d.ecosystem == "npm")); } #[test] fn parses_go_sum_and_drops_go_mod_lines() { let text = "\ github.com/a/b v1.2.3 h1:abc= github.com/a/b v1.2.3/go.mod h1:def= github.com/c/d v0.0.0-20210101000000-abcdef123456 h1:ghi= github.com/c/d v0.0.0-20210101000000-abcdef123456/go.mod h1:jkl= "; let deps = parse_go_sum(text, "go.sum"); assert_eq!(deps.len(), 2); assert_eq!(deps[0].ecosystem, "Go"); assert_eq!(deps[0].package, "github.com/a/b"); assert_eq!(deps[0].version, "v1.2.3"); } }