Skip to main content

rto_exec/adapter/
osv_scanner.rs

1//! `osv-scanner` — OSV.dev advisories against resolved dependency manifests.
2//!
3//! This is the analyzer that makes the dependency axis of the coverage matrix
4//! (ADR-0018) match the SAST axis. `cargo-audit` reads `Cargo.lock` and nothing
5//! else; `osv-scanner` reads the lockfiles of every ecosystem the project uses —
6//! `requirements.txt`, `poetry.lock`, `package-lock.json`, `yarn.lock`,
7//! `gradle.lockfile`, `pom.xml`, `Cargo.lock` and more — against one database
8//! family with one output format.
9//!
10//! # One finding per *group*, not per vulnerability entry
11//!
12//! OSV.dev carries the same advisory under several ids: a Rust advisory arrives
13//! as both `RUSTSEC-2020-0071` and `GHSA-wcg3-cvx6-7396`, and `osv-scanner`
14//! lists **both** in `vulnerabilities`. It also resolves them itself, in
15//! `groups`, where one entry names every id that is the same advisory.
16//!
17//! This adapter emits one finding per group. Emitting one per `vulnerabilities`
18//! entry would double-count every advisory that GitHub has also assigned a GHSA
19//! id to — a count that is wrong in the direction that looks like more work than
20//! there is. The group's whole id and alias set is preserved in `meta.aliases`,
21//! which is what the reporting layer's cross-reference joins on (see
22//! [`crate::crossref`]).
23//!
24//! # Three things this tool does that its documentation does not say
25//!
26//! All three were found by running `osv-scanner` 2.5.0, and each would have been
27//! a silent defect:
28//!
29//! 1. **Reported paths are absolute, even when the scan target is `.`.** Given
30//!    `scan source --recursive .` with the working directory set to the
31//!    worktree, every `results[].source.path` still comes back as a full
32//!    filesystem path. Storing that verbatim would put the user's home directory
33//!    into a persisted finding key — the same class of defect as semgrep's rule
34//!    id rewriting (ADR-0018) — and [`crate::check_reported_path`] would refuse
35//!    it besides. The path is made worktree-relative here, and a path that is
36//!    not under the worktree keeps [`UNKNOWN_SOURCE`] rather than being invented.
37//! 2. **`--offline-vulnerabilities` on its own consults no local database.**
38//!    With only that flag the scanner reports **zero findings and exits `0`** —
39//!    a clean bill of health produced by consulting nothing. The database is
40//!    loaded only under `--offline` (or `--download-offline-databases`), so
41//!    `--offline` is what this adapter passes, and it is not a stylistic choice.
42//! 3. **A missing database under `--offline` fails loudly, and that is wanted.**
43//!    `--offline --local-db-path <dir>` with no database there exits `127` with
44//!    "no offline version of the OSV database is available". `127` is not in
45//!    [`Invocation::success_statuses`], so the run fails instead of recording an
46//!    empty result — which is the whole difference between this configuration
47//!    and the one above.
48//!
49//! # Severity is a mapping, and it is the *same* mapping `cargo-audit` uses
50//!
51//! An OSV record publishes a qualitative level only sometimes: GitHub-sourced
52//! records carry `database_specific.severity` (`LOW`/`MODERATE`/`HIGH`/
53//! `CRITICAL`), and `RustSec`-sourced records carry
54//! `affected[].database_specific.informational` (`unmaintained`, `unsound`,
55//! `notice`). Both are mapped here, and the informational mapping is
56//! deliberately identical to [`crate::adapter::cargo_audit`]'s — so when the two
57//! analyzers report the same Rust advisory, the cross-reference shows one
58//! advisory at one severity rather than two that disagree about it.
59//!
60//! A record with no published level at all is a vulnerability that a curated
61//! database chose to publish, so it is graded [`Severity::High`] on the same
62//! reasoning `cargo-audit` grades `vulnerability` high. The raw CVSS vectors and
63//! the scanner's own `max_severity` score are preserved verbatim in `meta`;
64//! computing a base score is not done here for the reason ADR-0018 gives.
65//!
66//! @rto:0012
67//! @rto:0014
68//! @rto:0018
69
70use 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
80/// The analyzer id, and the first component of every finding key it produces.
81pub const ANALYZER: &str = "osv-scanner";
82
83/// Asset id of the pinned per-ecosystem OSV databases.
84pub const DB_ASSET: &str = "osv-db";
85
86/// How to obtain `osv-scanner`, for the refusal that finds it absent.
87///
88/// One of the two hints with **no command**, and that is the honest answer
89/// rather than a gap. Upstream's install page offers scoop, winget, brew,
90/// pacman, apk, pkg, `pkg_add`, a prebuilt SLSA3 binary and `go install`, and
91/// ranks none of them: eight of those are the reader's-package-manager guess the
92/// refusals checklist forbids, and the ninth needs a Go toolchain this reader
93/// has no reason to have. So the page is the way forward, and a command invented
94/// to fill the slot would be the plausible-but-wrong answer that costs an hour
95/// before anyone doubts it.
96///
97/// "No command" means no [`Line::Command`], which is now what the code says.
98/// This carried the URL as one for a revision, directly under the sentence
99/// denying it had a command — the same slip as
100/// [`crate::adapter::RUST_TOOLCHAIN`], the other hint with nothing to paste. The
101/// page is a `Note` prefixed [`crate::adapter::URL_PREFIX`], as in every hint
102/// that does have a command.
103const 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
116/// Stands in for the manifest path in a finding's identity when the reported
117/// path could not be placed inside the worktree.
118///
119/// An `osv-scanner` finding is a claim about *a package version resolved by a
120/// particular manifest*, so the manifest is part of its identity — two lockfiles
121/// in one repository can pin the same vulnerable version, and those are two
122/// findings, not one. When the reported path is not under the worktree the
123/// identity stays well-formed and says so, rather than keying on an absolute
124/// path that would differ between machines.
125pub const UNKNOWN_SOURCE: &str = "unknown-source";
126
127/// The adapter.
128#[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        // The ecosystems this build provisions a database for. `osv-scanner`
142        // supports more; claiming them here would claim coverage the pinned
143        // asset does not provide, since a run consults only the databases that
144        // were prefetched.
145        &["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                // Egress configured off, and — unlike
167                // `--offline-vulnerabilities` alone — this is the flag that
168                // actually makes the pinned local database be consulted. See
169                // the module docs; the difference between the two is a silent
170                // empty result.
171                "--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                // An explicit target. Without one the scanner starts its
178                // filesystem walk at the root of the filesystem rather than at
179                // the working directory.
180                ".".to_owned(),
181            ],
182            // 0 = clean, 1 = vulnerabilities found. Everything else — including
183            // the 127 a missing database produces — is a failed scan, and a
184            // failed scan must not be stored as a clean one.
185            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            // `osv-scanner --format json` carries no version field, so a report
213            // ingested from CI records "unknown" unless the caller learned the
214            // version another way (a subprocess run asks the binary).
215            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 are not a thing for osv-scanner; the databases are.
220            rules_digest: None,
221            image_digest: None,
222            // The scanner never reports which database snapshot it read, so the
223            // caller's provisioning record is the only staleness evidence there
224            // is — the same position `cargo audit --db` leaves us in.
225            advisory_db: ctx.advisory_db.clone(),
226            source: ctx.source.clone(),
227            findings,
228        })
229    }
230}
231
232/// One scanned package's groups → findings, appended to `into`.
233fn 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        // Deterministic representative: the group's ids sorted, first one. The
261        // choice never affects whether two findings cross-reference, because
262        // that join is over the whole alias set rather than this one id.
263        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        // Every vulnerability entry the group covers, so severity and prose come
278        // from all the ids for this advisory rather than from whichever one the
279        // scanner happened to list first.
280        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            // Advisory, ecosystem, package, version, manifest — the same shape
306            // as `cargo-audit`'s recipe, with the ecosystem added because
307            // `osv-scanner` reads more than one.
308            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            // The claim is about a resolved dependency; the manifest that
320            // resolved it is the file that decides it.
321            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                // Every id and alias for this advisory. The cross-reference in
328                // `crate::crossref` joins on this set, so it is the load-bearing
329                // field rather than a decoration.
330                "aliases": aliases,
331                "ids": ids,
332                // The scanner's own CVSS base score, verbatim and unparsed. An
333                // empty string is what it reports for an advisory with no score.
334                "max_severity": group.max_severity,
335                // RustSec's informational kind, where OSV carried one through.
336                "informational": informational(&members),
337                "cvss": cvss_vectors(&members),
338                "withdrawn": members.iter().find_map(|v| v.withdrawn.clone()),
339                // The manifest this claim came from, as it appears in the
340                // identity — relative where it could be placed in the worktree,
341                // and `UNKNOWN_SOURCE` where it could not.
342                "source": source,
343            }),
344        });
345    }
346    Ok(())
347}
348
349/// The groups to convert: the scanner's own, or one per vulnerability when it
350/// reported none.
351///
352/// `groups` is how `osv-scanner` says "these ids are the same advisory". A build
353/// or a version that omits it must still produce findings rather than silently
354/// nothing, so each vulnerability becomes its own single-id group.
355fn 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
370/// Every identifier this advisory is known by, sorted and deduplicated: the
371/// group's ids, the group's aliases, and each member record's own aliases.
372fn 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
387/// The `RustSec` informational kind carried through by OSV, if any member has one.
388fn 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
399/// Every CVSS vector the members publish, verbatim and unscored.
400fn 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
412/// The severity for a group: the highest any of its records publishes.
413///
414/// The informational arm is deliberately the same mapping
415/// [`crate::adapter::cargo_audit`] applies, so the two analyzers agree about a
416/// Rust advisory they both report.
417fn 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    // A curated database published this record and gave it no qualitative level.
427    // That is a vulnerability, and it is graded on the same reasoning
428    // `cargo-audit` grades RustSec's `vulnerability` kind high.
429    best.unwrap_or(Severity::High)
430}
431
432/// Every qualitative level one record publishes.
433fn 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
458/// GitHub's qualitative severity, which OSV carries verbatim.
459fn from_github(token: &str) -> Severity {
460    match token.to_ascii_lowercase().as_str() {
461        "critical" => Severity::Critical,
462        "high" => Severity::High,
463        // GitHub's middle level is "moderate"; Roteiro's is "medium".
464        "moderate" | "medium" => Severity::Medium,
465        "low" => Severity::Low,
466        other => Severity::from_token(other),
467    }
468}
469
470/// `RustSec`'s informational kind — the same mapping `cargo-audit` uses.
471fn 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
480/// Order the levels so "the highest of these" has an answer. A level nobody
481/// assigned ranks below every level somebody did.
482fn 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
493/// The manifest path as a worktree-relative identity component.
494///
495/// `osv-scanner` reports absolute paths even when told to scan `.`, so this is
496/// what keeps the user's home directory out of a stored finding key. A path that
497/// is not under the worktree becomes [`UNKNOWN_SOURCE`]: guessing at a relative
498/// form would invent a location the scan never described.
499fn 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    // The worktree path is taken as the caller supplied it and, failing that, in
511    // canonical form: on macOS a checkout under `/tmp` is reported back under
512    // `/private/tmp`, and those are the same directory.
513    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
528/// Separators as the store records them, on every platform.
529fn normalise(path: &str) -> String {
530    path.replace('\\', "/")
531}
532
533/// The shape of `osv-scanner --format json`, narrowed to what is needed. Unknown
534/// fields are ignored so an `osv-scanner` upgrade that adds keys does not break
535/// ingest.
536#[derive(Debug, Deserialize)]
537struct ScanOutput {
538    /// Absent rather than empty distinguishes "not an osv-scanner report" from
539    /// "a clean scan".
540    #[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    /// Trimmed from a real `osv-scanner` 2.5.0 offline run. The `openssl` entry
657    /// is the load-bearing one: the same advisory appears twice, as
658    /// `RUSTSEC-2023-0072` and as `GHSA-xphf-cx8h-7q9g`, and `groups` says they
659    /// are one thing.
660    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    /// The headline behaviour: the `openssl` advisory is listed twice by the
738    /// scanner and becomes **one** finding, because `groups` already says the
739    /// two ids are the same advisory. One finding per vulnerability entry would
740    /// double every advisory GitHub has also assigned a GHSA id to.
741    #[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        // …and both ids remain addressable on the one finding.
755        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    /// The scanner reports absolute paths even when the target is `.`. Storing
786    /// one verbatim would put the user's home directory into a persisted finding
787    /// key — and the shared preflight would refuse it besides.
788    #[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    /// A report about a tree this checkout does not have still yields a
801    /// well-formed identity, and one that says the location is unknown rather
802    /// than inventing a relative path.
803    #[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    /// The informational mapping is the one `cargo-audit` uses, so the two
838    /// analyzers do not disagree about a Rust advisory they both report.
839    #[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    /// A record a curated database published with no qualitative level at all is
862    /// still a vulnerability, and is graded on the same reasoning `cargo-audit`
863    /// grades `RustSec`'s `vulnerability` kind high.
864    #[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    /// A group whose records disagree takes the highest level any of them
877    /// publishes, so a cross-referenced pair does not read as two severities.
878    #[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    /// Two manifests in one repository can pin the same vulnerable version. They
894    /// are two findings, and the manifest in the identity is what keeps them
895    /// distinct rather than colliding into one key.
896    #[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    /// A version of the scanner that reports no `groups` must still produce
915    /// findings — silently nothing is the one answer a security tool may not
916    /// give.
917    #[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    /// `--offline-vulnerabilities` alone consults no database and reports a
962    /// clean scan; `--offline` is what actually loads the pinned one. Verified
963    /// against osv-scanner 2.5.0 — see the module docs.
964    #[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        // An explicit target: without one the walk starts at the filesystem root.
986        assert_eq!(invocation.args.last().map(String::as_str), Some("."));
987        // 127 (no database) must not read as a completed scan.
988        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}