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, Invocation, NativeContext};
75use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
76use crate::runner::ExecError;
77use rto_graph::Severity;
78
79/// The analyzer id, and the first component of every finding key it produces.
80pub const ANALYZER: &str = "osv-scanner";
81
82/// Asset id of the pinned per-ecosystem OSV databases.
83pub const DB_ASSET: &str = "osv-db";
84
85/// Stands in for the manifest path in a finding's identity when the reported
86/// path could not be placed inside the worktree.
87///
88/// An `osv-scanner` finding is a claim about *a package version resolved by a
89/// particular manifest*, so the manifest is part of its identity — two lockfiles
90/// in one repository can pin the same vulnerable version, and those are two
91/// findings, not one. When the reported path is not under the worktree the
92/// identity stays well-formed and says so, rather than keying on an absolute
93/// path that would differ between machines.
94pub const UNKNOWN_SOURCE: &str = "unknown-source";
95
96/// The adapter.
97#[derive(Debug, Clone, Copy)]
98pub struct OsvScanner;
99
100impl Adapter for OsvScanner {
101    fn analyzer(&self) -> &'static str {
102        ANALYZER
103    }
104
105    fn summary(&self) -> &'static str {
106        "OSV.dev advisories against resolved lockfiles (Python, Java, Node, Rust dependencies)"
107    }
108
109    fn languages(&self) -> &'static [&'static str] {
110        // The ecosystems this build provisions a database for. `osv-scanner`
111        // supports more; claiming them here would claim coverage the pinned
112        // asset does not provide, since a run consults only the databases that
113        // were prefetched.
114        &["python", "java", "javascript", "typescript", "rust"]
115    }
116
117    fn asset_ids(&self) -> &'static [&'static str] {
118        &[DB_ASSET]
119    }
120
121    fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
122        Invocation {
123            program: "osv-scanner".to_owned(),
124            args: vec![
125                "scan".to_owned(),
126                "source".to_owned(),
127                // Egress configured off, and — unlike
128                // `--offline-vulnerabilities` alone — this is the flag that
129                // actually makes the pinned local database be consulted. See
130                // the module docs; the difference between the two is a silent
131                // empty result.
132                "--offline".to_owned(),
133                "--local-db-path".to_owned(),
134                assets.arg(DB_ASSET),
135                "--format".to_owned(),
136                "json".to_owned(),
137                "--recursive".to_owned(),
138                // An explicit target. Without one the scanner starts its
139                // filesystem walk at the root of the filesystem rather than at
140                // the working directory.
141                ".".to_owned(),
142            ],
143            // 0 = clean, 1 = vulnerabilities found. Everything else — including
144            // the 127 a missing database produces — is a failed scan, and a
145            // failed scan must not be stored as a clean one.
146            success_statuses: vec![0, 1],
147        }
148    }
149
150    fn normalize(
151        &self,
152        native: &[u8],
153        ctx: &NativeContext<'_>,
154    ) -> Result<NormalizedReport, ExecError> {
155        let output: ScanOutput = serde_json::from_slice(native)?;
156        let Some(results) = output.results else {
157            return Err(ExecError::MalformedReport(
158                "not an osv-scanner report: no `results` array".to_owned(),
159            ));
160        };
161
162        let mut findings = Vec::new();
163        for result in &results {
164            let source = source_component(result.source.path.as_deref(), ctx.worktree);
165            for entry in &result.packages {
166                convert_package(entry, &source, &mut findings)?;
167            }
168        }
169
170        Ok(NormalizedReport {
171            schema: REPORT_SCHEMA.to_owned(),
172            analyzer: ANALYZER.to_owned(),
173            // `osv-scanner --format json` carries no version field, so a report
174            // ingested from CI records "unknown" unless the caller learned the
175            // version another way (a subprocess run asks the binary).
176            analyzer_version: ctx.version_or(None),
177            started_at: ctx.started_at.clone(),
178            ended_at: ctx.ended_at.clone(),
179            exit_status: ctx.exit_status,
180            // Rules are not a thing for osv-scanner; the databases are.
181            rules_digest: None,
182            image_digest: None,
183            // The scanner never reports which database snapshot it read, so the
184            // caller's provisioning record is the only staleness evidence there
185            // is — the same position `cargo audit --db` leaves us in.
186            advisory_db: ctx.advisory_db.clone(),
187            source: ctx.source.clone(),
188            findings,
189        })
190    }
191}
192
193/// One scanned package's groups → findings, appended to `into`.
194fn convert_package(
195    entry: &PackageEntry,
196    source: &str,
197    into: &mut Vec<ReportFinding>,
198) -> Result<(), ExecError> {
199    let Some(package) = entry.package.as_ref() else {
200        return Err(ExecError::MalformedReport(
201            "an osv-scanner package entry has no `package` object".to_owned(),
202        ));
203    };
204    if package.name.trim().is_empty() {
205        return Err(ExecError::MalformedReport(
206            "an osv-scanner package entry has an unnamed package".to_owned(),
207        ));
208    }
209    let version = if package.version.trim().is_empty() {
210        "unknown-version"
211    } else {
212        package.version.trim()
213    };
214    let ecosystem = if package.ecosystem.trim().is_empty() {
215        "unknown-ecosystem"
216    } else {
217        package.ecosystem.trim()
218    };
219
220    for group in groups_of(entry) {
221        // Deterministic representative: the group's ids sorted, first one. The
222        // choice never affects whether two findings cross-reference, because
223        // that join is over the whole alias set rather than this one id.
224        let mut ids: Vec<&str> = group
225            .ids
226            .iter()
227            .map(|id| id.trim())
228            .filter(|id| !id.is_empty())
229            .collect();
230        ids.sort_unstable();
231        ids.dedup();
232        let Some(&rule) = ids.first() else {
233            return Err(ExecError::MalformedReport(
234                "an osv-scanner group names no advisory id".to_owned(),
235            ));
236        };
237
238        // Every vulnerability entry the group covers, so severity and prose come
239        // from all the ids for this advisory rather than from whichever one the
240        // scanner happened to list first.
241        let members: Vec<&Vulnerability> = entry
242            .vulnerabilities
243            .iter()
244            .filter(|v| ids.contains(&v.id.trim()))
245            .collect();
246
247        let aliases = alias_set(&group, &members);
248        let title = members
249            .iter()
250            .filter_map(|v| v.summary.as_deref())
251            .map(str::trim)
252            .find(|s| !s.is_empty())
253            .map_or_else(
254                || format!("{} {version} is affected by {rule}", package.name),
255                str::to_owned,
256            );
257        let message = members
258            .iter()
259            .filter_map(|v| v.details.as_deref())
260            .map(str::trim)
261            .find(|s| !s.is_empty())
262            .unwrap_or_default()
263            .to_owned();
264
265        into.push(ReportFinding {
266            // Advisory, ecosystem, package, version, manifest — the same shape
267            // as `cargo-audit`'s recipe, with the ecosystem added because
268            // `osv-scanner` reads more than one.
269            identity: vec![
270                rule.to_owned(),
271                ecosystem.to_owned(),
272                package.name.clone(),
273                version.to_owned(),
274                source.to_owned(),
275            ],
276            rule: rule.to_owned(),
277            severity: severity(&members),
278            title,
279            message,
280            // The claim is about a resolved dependency; the manifest that
281            // resolved it is the file that decides it.
282            path: (source != UNKNOWN_SOURCE).then(|| source.to_owned()),
283            span: None,
284            meta: serde_json::json!({
285                "ecosystem": ecosystem,
286                "package": package.name,
287                "version": version,
288                // Every id and alias for this advisory. The cross-reference in
289                // `crate::crossref` joins on this set, so it is the load-bearing
290                // field rather than a decoration.
291                "aliases": aliases,
292                "ids": ids,
293                // The scanner's own CVSS base score, verbatim and unparsed. An
294                // empty string is what it reports for an advisory with no score.
295                "max_severity": group.max_severity,
296                // RustSec's informational kind, where OSV carried one through.
297                "informational": informational(&members),
298                "cvss": cvss_vectors(&members),
299                "withdrawn": members.iter().find_map(|v| v.withdrawn.clone()),
300                // The manifest this claim came from, as it appears in the
301                // identity — relative where it could be placed in the worktree,
302                // and `UNKNOWN_SOURCE` where it could not.
303                "source": source,
304            }),
305        });
306    }
307    Ok(())
308}
309
310/// The groups to convert: the scanner's own, or one per vulnerability when it
311/// reported none.
312///
313/// `groups` is how `osv-scanner` says "these ids are the same advisory". A build
314/// or a version that omits it must still produce findings rather than silently
315/// nothing, so each vulnerability becomes its own single-id group.
316fn groups_of(entry: &PackageEntry) -> Vec<Group> {
317    if !entry.groups.is_empty() {
318        return entry.groups.clone();
319    }
320    entry
321        .vulnerabilities
322        .iter()
323        .map(|v| Group {
324            ids: vec![v.id.clone()],
325            aliases: v.aliases.clone(),
326            max_severity: String::new(),
327        })
328        .collect()
329}
330
331/// Every identifier this advisory is known by, sorted and deduplicated: the
332/// group's ids, the group's aliases, and each member record's own aliases.
333fn alias_set(group: &Group, members: &[&Vulnerability]) -> Vec<String> {
334    let mut all: Vec<String> = group
335        .ids
336        .iter()
337        .chain(group.aliases.iter())
338        .chain(members.iter().flat_map(|v| v.aliases.iter()))
339        .chain(members.iter().map(|v| &v.id))
340        .map(|id| id.trim().to_owned())
341        .filter(|id| !id.is_empty())
342        .collect();
343    all.sort();
344    all.dedup();
345    all
346}
347
348/// The `RustSec` informational kind carried through by OSV, if any member has one.
349fn informational(members: &[&Vulnerability]) -> Option<String> {
350    members
351        .iter()
352        .flat_map(|v| v.affected.iter())
353        .filter_map(|a| a.database_specific.as_ref())
354        .filter_map(|d| d.informational.as_deref())
355        .map(str::trim)
356        .find(|k| !k.is_empty())
357        .map(str::to_owned)
358}
359
360/// Every CVSS vector the members publish, verbatim and unscored.
361fn cvss_vectors(members: &[&Vulnerability]) -> Vec<String> {
362    let mut out: Vec<String> = members
363        .iter()
364        .flat_map(|v| v.severity.iter())
365        .map(|s| s.score.trim().to_owned())
366        .filter(|s| !s.is_empty())
367        .collect();
368    out.sort();
369    out.dedup();
370    out
371}
372
373/// The severity for a group: the highest any of its records publishes.
374///
375/// The informational arm is deliberately the same mapping
376/// [`crate::adapter::cargo_audit`] applies, so the two analyzers agree about a
377/// Rust advisory they both report.
378fn severity(members: &[&Vulnerability]) -> Severity {
379    let mut best: Option<Severity> = None;
380    for member in members {
381        for level in member_levels(member) {
382            if best.as_ref().is_none_or(|b| rank(&level) > rank(b)) {
383                best = Some(level);
384            }
385        }
386    }
387    // A curated database published this record and gave it no qualitative level.
388    // That is a vulnerability, and it is graded on the same reasoning
389    // `cargo-audit` grades RustSec's `vulnerability` kind high.
390    best.unwrap_or(Severity::High)
391}
392
393/// Every qualitative level one record publishes.
394fn member_levels(member: &Vulnerability) -> Vec<Severity> {
395    let mut levels = Vec::new();
396    if let Some(token) = member
397        .database_specific
398        .as_ref()
399        .and_then(|d| d.severity.as_deref())
400        .map(str::trim)
401        .filter(|s| !s.is_empty())
402    {
403        levels.push(from_github(token));
404    }
405    for affected in &member.affected {
406        if let Some(kind) = affected
407            .database_specific
408            .as_ref()
409            .and_then(|d| d.informational.as_deref())
410            .map(str::trim)
411            .filter(|k| !k.is_empty())
412        {
413            levels.push(from_informational(kind));
414        }
415    }
416    levels
417}
418
419/// GitHub's qualitative severity, which OSV carries verbatim.
420fn from_github(token: &str) -> Severity {
421    match token.to_ascii_lowercase().as_str() {
422        "critical" => Severity::Critical,
423        "high" => Severity::High,
424        // GitHub's middle level is "moderate"; Roteiro's is "medium".
425        "moderate" | "medium" => Severity::Medium,
426        "low" => Severity::Low,
427        other => Severity::from_token(other),
428    }
429}
430
431/// `RustSec`'s informational kind — the same mapping `cargo-audit` uses.
432fn from_informational(kind: &str) -> Severity {
433    match kind {
434        "unsound" => Severity::Medium,
435        "unmaintained" | "yanked" => Severity::Low,
436        "notice" => Severity::Info,
437        other => Severity::from_token(other),
438    }
439}
440
441/// Order the levels so "the highest of these" has an answer. A level nobody
442/// assigned ranks below every level somebody did.
443fn rank(severity: &Severity) -> u8 {
444    match severity {
445        Severity::Critical => 5,
446        Severity::High => 4,
447        Severity::Medium => 3,
448        Severity::Low => 2,
449        Severity::Info => 1,
450        Severity::Other(_) => 0,
451    }
452}
453
454/// The manifest path as a worktree-relative identity component.
455///
456/// `osv-scanner` reports absolute paths even when told to scan `.`, so this is
457/// what keeps the user's home directory out of a stored finding key. A path that
458/// is not under the worktree becomes [`UNKNOWN_SOURCE`]: guessing at a relative
459/// form would invent a location the scan never described.
460fn source_component(reported: Option<&str>, worktree: Option<&Path>) -> String {
461    let Some(reported) = reported.map(str::trim).filter(|p| !p.is_empty()) else {
462        return UNKNOWN_SOURCE.to_owned();
463    };
464    let path = Path::new(reported);
465    if path.is_relative() {
466        return normalise(reported);
467    }
468    let Some(worktree) = worktree else {
469        return UNKNOWN_SOURCE.to_owned();
470    };
471    // The worktree path is taken as the caller supplied it and, failing that, in
472    // canonical form: on macOS a checkout under `/tmp` is reported back under
473    // `/private/tmp`, and those are the same directory.
474    let candidates = [
475        Some(worktree.to_path_buf()),
476        std::fs::canonicalize(worktree).ok(),
477    ];
478    for candidate in candidates.into_iter().flatten() {
479        if let Ok(relative) = path.strip_prefix(&candidate) {
480            let relative = relative.to_string_lossy();
481            if !relative.is_empty() {
482                return normalise(&relative);
483            }
484        }
485    }
486    UNKNOWN_SOURCE.to_owned()
487}
488
489/// Separators as the store records them, on every platform.
490fn normalise(path: &str) -> String {
491    path.replace('\\', "/")
492}
493
494/// The shape of `osv-scanner --format json`, narrowed to what is needed. Unknown
495/// fields are ignored so an `osv-scanner` upgrade that adds keys does not break
496/// ingest.
497#[derive(Debug, Deserialize)]
498struct ScanOutput {
499    /// Absent rather than empty distinguishes "not an osv-scanner report" from
500    /// "a clean scan".
501    #[serde(default)]
502    results: Option<Vec<ScanResult>>,
503}
504
505#[derive(Debug, Deserialize)]
506struct ScanResult {
507    #[serde(default)]
508    source: Source,
509    #[serde(default)]
510    packages: Vec<PackageEntry>,
511}
512
513#[derive(Debug, Default, Deserialize)]
514struct Source {
515    #[serde(default)]
516    path: Option<String>,
517}
518
519#[derive(Debug, Deserialize)]
520struct PackageEntry {
521    #[serde(default)]
522    package: Option<PackageId>,
523    #[serde(default)]
524    vulnerabilities: Vec<Vulnerability>,
525    #[serde(default)]
526    groups: Vec<Group>,
527}
528
529#[derive(Debug, Deserialize)]
530struct PackageId {
531    #[serde(default)]
532    name: String,
533    #[serde(default)]
534    version: String,
535    #[serde(default)]
536    ecosystem: String,
537}
538
539#[derive(Debug, Clone, Deserialize)]
540struct Group {
541    #[serde(default)]
542    ids: Vec<String>,
543    #[serde(default)]
544    aliases: Vec<String>,
545    #[serde(default)]
546    max_severity: String,
547}
548
549#[derive(Debug, Deserialize)]
550struct Vulnerability {
551    #[serde(default)]
552    id: String,
553    #[serde(default)]
554    summary: Option<String>,
555    #[serde(default)]
556    details: Option<String>,
557    #[serde(default)]
558    aliases: Vec<String>,
559    #[serde(default)]
560    withdrawn: Option<String>,
561    #[serde(default)]
562    severity: Vec<SeverityScore>,
563    #[serde(default)]
564    database_specific: Option<DatabaseSpecific>,
565    #[serde(default)]
566    affected: Vec<Affected>,
567}
568
569#[derive(Debug, Deserialize)]
570struct SeverityScore {
571    #[serde(default)]
572    score: String,
573}
574
575#[derive(Debug, Deserialize)]
576struct DatabaseSpecific {
577    #[serde(default)]
578    severity: Option<String>,
579}
580
581#[derive(Debug, Deserialize)]
582struct Affected {
583    #[serde(default)]
584    database_specific: Option<AffectedDatabaseSpecific>,
585}
586
587#[derive(Debug, Deserialize)]
588struct AffectedDatabaseSpecific {
589    #[serde(default)]
590    informational: Option<String>,
591}
592
593#[cfg(test)]
594mod tests {
595    use super::{ANALYZER, DB_ASSET, OsvScanner, UNKNOWN_SOURCE, source_component};
596    use crate::adapter::{Adapter, AssetPaths, NativeContext};
597    use crate::runner::ExecError;
598    use rto_graph::{Severity, SourceIdentity};
599
600    static SOURCE: std::sync::LazyLock<SourceIdentity> =
601        std::sync::LazyLock::new(SourceIdentity::default);
602
603    fn ctx(worktree: Option<&'static str>) -> NativeContext<'static> {
604        NativeContext {
605            started_at: "2026-08-16T09:00:00Z".to_owned(),
606            ended_at: "2026-08-16T09:00:06Z".to_owned(),
607            analyzer_version: Some("2.5.0".to_owned()),
608            exit_status: 1,
609            source: &SOURCE,
610            rules_digest: None,
611            advisory_db: None,
612            worktree: worktree.map(std::path::Path::new),
613            snippets: &crate::snippet::NoSnippets,
614        }
615    }
616
617    /// Trimmed from a real `osv-scanner` 2.5.0 offline run. The `openssl` entry
618    /// is the load-bearing one: the same advisory appears twice, as
619    /// `RUSTSEC-2023-0072` and as `GHSA-xphf-cx8h-7q9g`, and `groups` says they
620    /// are one thing.
621    const NATIVE: &str = r#"{
622      "results": [
623        {
624          "source": {"path": "/repo/Cargo.lock", "type": "lockfile"},
625          "packages": [
626            {
627              "package": {"name": "openssl", "version": "0.10.55", "ecosystem": "crates.io"},
628              "vulnerabilities": [
629                {
630                  "id": "RUSTSEC-2023-0072",
631                  "summary": "`openssl` `X509StoreRef::objects` is unsound",
632                  "details": "The objects method is unsound.",
633                  "aliases": ["GHSA-xphf-cx8h-7q9g"],
634                  "database_specific": {"license": "CC0-1.0"},
635                  "affected": [{"database_specific": {"informational": "unsound", "cvss": null}}]
636                },
637                {
638                  "id": "GHSA-xphf-cx8h-7q9g",
639                  "summary": "`openssl` `X509StoreRef::objects` is unsound",
640                  "aliases": ["RUSTSEC-2023-0072"],
641                  "database_specific": {"severity": "MODERATE"},
642                  "affected": [{"database_specific": {}}]
643                }
644              ],
645              "groups": [
646                {
647                  "ids": ["RUSTSEC-2023-0072", "GHSA-xphf-cx8h-7q9g"],
648                  "aliases": ["GHSA-xphf-cx8h-7q9g", "RUSTSEC-2023-0072"],
649                  "max_severity": ""
650                }
651              ]
652            },
653            {
654              "package": {"name": "derivative", "version": "2.2.0", "ecosystem": "crates.io"},
655              "vulnerabilities": [
656                {
657                  "id": "RUSTSEC-2024-0388",
658                  "summary": "`derivative` is unmaintained; consider using an alternative",
659                  "database_specific": {"license": "CC0-1.0"},
660                  "affected": [{"database_specific": {"informational": "unmaintained"}}]
661                }
662              ],
663              "groups": [
664                {"ids": ["RUSTSEC-2024-0388"], "aliases": ["RUSTSEC-2024-0388"], "max_severity": ""}
665              ]
666            }
667          ]
668        },
669        {
670          "source": {"path": "/repo/app/package-lock.json", "type": "lockfile"},
671          "packages": [
672            {
673              "package": {"name": "lodash", "version": "4.17.15", "ecosystem": "npm"},
674              "vulnerabilities": [
675                {
676                  "id": "GHSA-p6mc-m468-83gw",
677                  "summary": "Prototype Pollution in lodash",
678                  "details": "Versions prior to 4.17.21 are vulnerable.",
679                  "aliases": ["CVE-2020-8203"],
680                  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N"}],
681                  "database_specific": {"severity": "HIGH"},
682                  "affected": [{"database_specific": {}}]
683                }
684              ],
685              "groups": [
686                {
687                  "ids": ["GHSA-p6mc-m468-83gw"],
688                  "aliases": ["CVE-2020-8203", "GHSA-p6mc-m468-83gw"],
689                  "max_severity": "7.4"
690                }
691              ]
692            }
693          ]
694        }
695      ]
696    }"#;
697
698    /// The headline behaviour: the `openssl` advisory is listed twice by the
699    /// scanner and becomes **one** finding, because `groups` already says the
700    /// two ids are the same advisory. One finding per vulnerability entry would
701    /// double every advisory GitHub has also assigned a GHSA id to.
702    #[test]
703    fn a_duplicated_advisory_becomes_one_finding_not_two() {
704        let report = OsvScanner
705            .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
706            .expect("parse");
707        assert_eq!(report.analyzer, ANALYZER);
708        assert_eq!(report.findings.len(), 3, "openssl, derivative, lodash");
709        let openssl: Vec<_> = report
710            .findings
711            .iter()
712            .filter(|f| f.meta["package"] == "openssl")
713            .collect();
714        assert_eq!(openssl.len(), 1);
715        // …and both ids remain addressable on the one finding.
716        let aliases = openssl[0].meta["aliases"].as_array().expect("aliases");
717        assert!(aliases.iter().any(|a| a == "RUSTSEC-2023-0072"));
718        assert!(aliases.iter().any(|a| a == "GHSA-xphf-cx8h-7q9g"));
719    }
720
721    #[test]
722    fn uses_the_advisory_ecosystem_package_version_manifest_identity() {
723        let report = OsvScanner
724            .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
725            .expect("parse");
726        let lodash = report
727            .findings
728            .iter()
729            .find(|f| f.meta["package"] == "lodash")
730            .expect("lodash");
731        assert_eq!(
732            lodash.identity,
733            vec![
734                "GHSA-p6mc-m468-83gw",
735                "npm",
736                "lodash",
737                "4.17.15",
738                "app/package-lock.json"
739            ]
740        );
741        assert_eq!(lodash.path.as_deref(), Some("app/package-lock.json"));
742        assert_eq!(lodash.severity, Severity::High);
743        assert_eq!(lodash.meta["max_severity"], "7.4");
744    }
745
746    /// The scanner reports absolute paths even when the target is `.`. Storing
747    /// one verbatim would put the user's home directory into a persisted finding
748    /// key — and the shared preflight would refuse it besides.
749    #[test]
750    fn an_absolute_reported_path_is_made_worktree_relative() {
751        let report = OsvScanner
752            .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
753            .expect("parse");
754        for finding in &report.findings {
755            let path = finding.path.as_deref().expect("a path");
756            assert!(!path.starts_with('/'), "{path} is still absolute");
757            crate::runner::check_reported_path(path).expect("must pass the preflight");
758        }
759    }
760
761    /// A report about a tree this checkout does not have still yields a
762    /// well-formed identity, and one that says the location is unknown rather
763    /// than inventing a relative path.
764    #[test]
765    fn a_path_outside_the_worktree_is_named_not_guessed() {
766        let report = OsvScanner
767            .normalize(NATIVE.as_bytes(), &ctx(Some("/elsewhere")))
768            .expect("parse");
769        assert!(report.findings.iter().all(|f| f.path.is_none()));
770        assert!(
771            report
772                .findings
773                .iter()
774                .all(|f| f.identity[4] == UNKNOWN_SOURCE)
775        );
776    }
777
778    #[test]
779    fn source_components_cover_relative_absolute_and_unknown() {
780        let repo = std::path::Path::new("/repo");
781        assert_eq!(
782            source_component(Some("/repo/a/Cargo.lock"), Some(repo)),
783            "a/Cargo.lock"
784        );
785        assert_eq!(
786            source_component(Some("a/Cargo.lock"), Some(repo)),
787            "a/Cargo.lock"
788        );
789        assert_eq!(
790            source_component(Some("/other/Cargo.lock"), Some(repo)),
791            UNKNOWN_SOURCE
792        );
793        assert_eq!(source_component(Some("/repo/x"), None), UNKNOWN_SOURCE);
794        assert_eq!(source_component(None, Some(repo)), UNKNOWN_SOURCE);
795        assert_eq!(source_component(Some("   "), Some(repo)), UNKNOWN_SOURCE);
796    }
797
798    /// The informational mapping is the one `cargo-audit` uses, so the two
799    /// analyzers do not disagree about a Rust advisory they both report.
800    #[test]
801    fn informational_kinds_are_graded_the_way_cargo_audit_grades_them() {
802        let report = OsvScanner
803            .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
804            .expect("parse");
805        let unsound = report
806            .findings
807            .iter()
808            .find(|f| f.meta["package"] == "openssl")
809            .expect("openssl");
810        assert_eq!(unsound.severity, Severity::Medium);
811        assert_eq!(unsound.meta["informational"], "unsound");
812
813        let unmaintained = report
814            .findings
815            .iter()
816            .find(|f| f.meta["package"] == "derivative")
817            .expect("derivative");
818        assert_eq!(unmaintained.severity, Severity::Low);
819        assert_eq!(unmaintained.meta["informational"], "unmaintained");
820    }
821
822    /// A record a curated database published with no qualitative level at all is
823    /// still a vulnerability, and is graded on the same reasoning `cargo-audit`
824    /// grades `RustSec`'s `vulnerability` kind high.
825    #[test]
826    fn an_advisory_with_no_published_level_is_graded_high() {
827        let native = r#"{"results":[{"source":{"path":"/repo/Cargo.lock"},"packages":[
828            {"package":{"name":"x","version":"1.0.0","ecosystem":"crates.io"},
829             "vulnerabilities":[{"id":"OSV-1","summary":"bad"}],
830             "groups":[{"ids":["OSV-1"],"aliases":[],"max_severity":""}]}]}]}"#;
831        let report = OsvScanner
832            .normalize(native.as_bytes(), &ctx(Some("/repo")))
833            .expect("parse");
834        assert_eq!(report.findings[0].severity, Severity::High);
835    }
836
837    /// A group whose records disagree takes the highest level any of them
838    /// publishes, so a cross-referenced pair does not read as two severities.
839    #[test]
840    fn a_group_takes_the_highest_level_its_records_publish() {
841        let native = r#"{"results":[{"source":{"path":"/repo/p.json"},"packages":[
842            {"package":{"name":"x","version":"1.0.0","ecosystem":"npm"},
843             "vulnerabilities":[
844               {"id":"A-1","summary":"a","database_specific":{"severity":"LOW"}},
845               {"id":"B-1","summary":"b","database_specific":{"severity":"CRITICAL"}}],
846             "groups":[{"ids":["A-1","B-1"],"aliases":[],"max_severity":"9.8"}]}]}]}"#;
847        let report = OsvScanner
848            .normalize(native.as_bytes(), &ctx(Some("/repo")))
849            .expect("parse");
850        assert_eq!(report.findings.len(), 1);
851        assert_eq!(report.findings[0].severity, Severity::Critical);
852    }
853
854    /// Two manifests in one repository can pin the same vulnerable version. They
855    /// are two findings, and the manifest in the identity is what keeps them
856    /// distinct rather than colliding into one key.
857    #[test]
858    fn the_same_advisory_in_two_manifests_is_two_findings() {
859        let native = r#"{"results":[
860            {"source":{"path":"/repo/a/package-lock.json"},"packages":[
861              {"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
862               "vulnerabilities":[{"id":"G-1","summary":"pollution"}],
863               "groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]},
864            {"source":{"path":"/repo/b/package-lock.json"},"packages":[
865              {"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
866               "vulnerabilities":[{"id":"G-1","summary":"pollution"}],
867               "groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]}]}"#;
868        let report = OsvScanner
869            .normalize(native.as_bytes(), &ctx(Some("/repo")))
870            .expect("parse");
871        assert_eq!(report.findings.len(), 2);
872        assert_ne!(report.findings[0].identity, report.findings[1].identity);
873    }
874
875    /// A version of the scanner that reports no `groups` must still produce
876    /// findings — silently nothing is the one answer a security tool may not
877    /// give.
878    #[test]
879    fn vulnerabilities_without_groups_are_still_reported() {
880        let native = r#"{"results":[{"source":{"path":"/repo/req.txt"},"packages":[
881            {"package":{"name":"django","version":"2.2.0","ecosystem":"PyPI"},
882             "vulnerabilities":[{"id":"PYSEC-2019-10","summary":"sql injection"}]}]}]}"#;
883        let report = OsvScanner
884            .normalize(native.as_bytes(), &ctx(Some("/repo")))
885            .expect("parse");
886        assert_eq!(report.findings.len(), 1);
887        assert_eq!(report.findings[0].rule, "PYSEC-2019-10");
888    }
889
890    #[test]
891    fn a_clean_scan_is_a_valid_empty_report() {
892        let report = OsvScanner
893            .normalize(br#"{"results":[]}"#, &ctx(Some("/repo")))
894            .expect("parse");
895        assert!(report.findings.is_empty());
896    }
897
898    #[test]
899    fn refuses_output_that_is_not_an_osv_scanner_report() {
900        let err = OsvScanner
901            .normalize(br#"{"experimental_config":{}}"#, &ctx(Some("/repo")))
902            .expect_err("must be refused");
903        assert!(matches!(err, ExecError::MalformedReport(_)));
904        assert!(err.to_string().contains("no `results` array"), "{err}");
905
906        assert!(matches!(
907            OsvScanner.normalize(b"<html>", &ctx(Some("/repo"))),
908            Err(ExecError::Json(_))
909        ));
910    }
911
912    #[test]
913    fn refuses_a_package_entry_with_no_package() {
914        let native = r#"{"results":[{"source":{"path":"/repo/x"},"packages":[
915            {"vulnerabilities":[{"id":"A"}]}]}]}"#;
916        assert!(matches!(
917            OsvScanner.normalize(native.as_bytes(), &ctx(Some("/repo"))),
918            Err(ExecError::MalformedReport(_))
919        ));
920    }
921
922    /// `--offline-vulnerabilities` alone consults no database and reports a
923    /// clean scan; `--offline` is what actually loads the pinned one. Verified
924    /// against osv-scanner 2.5.0 — see the module docs.
925    #[test]
926    fn the_invocation_pins_the_database_and_really_goes_offline() {
927        let entries = [(DB_ASSET, std::path::PathBuf::from("/cache/osv"))];
928        let invocation = OsvScanner.command(&AssetPaths::new(&entries));
929        assert_eq!(invocation.program, "osv-scanner");
930        assert_eq!(invocation.args[0], "scan");
931        assert_eq!(invocation.args[1], "source");
932        assert!(invocation.args.contains(&"--offline".to_owned()));
933        assert!(
934            !invocation
935                .args
936                .contains(&"--offline-vulnerabilities".to_owned()),
937            "that flag alone consults nothing and reports a clean scan"
938        );
939        let db = invocation
940            .args
941            .iter()
942            .position(|a| a == "--local-db-path")
943            .map(|i| invocation.args[i + 1].clone())
944            .expect("a --local-db-path argument");
945        assert_eq!(db, "/cache/osv");
946        // An explicit target: without one the walk starts at the filesystem root.
947        assert_eq!(invocation.args.last().map(String::as_str), Some("."));
948        // 127 (no database) must not read as a completed scan.
949        assert_eq!(invocation.success_statuses, vec![0, 1]);
950    }
951
952    #[test]
953    fn covers_the_dependency_axis_for_the_ecosystems_it_provisions() {
954        assert!(OsvScanner.languages().contains(&"python"));
955        assert!(OsvScanner.languages().contains(&"java"));
956        assert!(OsvScanner.languages().contains(&"javascript"));
957        assert_eq!(OsvScanner.asset_ids(), &[DB_ASSET]);
958    }
959}