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