Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

src/gitlab.rs

//! GitLab **gemnasium** advisory import (feature `scan`).
//!
//! GitLab's advisory database (`gitlab-org/advisory-database`, the *gemnasium*
//! format) ships one YAML file per advisory. This module parses it into a
//! [`scan::Advisory`] — reusing the same range-native model the OSV import produces,
//! so a gemnasium advisory matches through the exact same [`crate::versions`]
//! machinery. Pure parse, no network (the airgap sealed-znippy path).
//!
//! Two pieces: [`parse_gemnasium_range`] turns the gemnasium **`affected_range`**
//! constraint string into modgunn's `Vec<Vec<RangeEvent>>` disjunction, and
//! [`parse_gemnasium_yaml`] scans the flat YAML for the coords/ids/severity and
//! assembles the advisory. No `yaml` dependency (lean-gate purity): the gemnasium
//! YAML is flat key/value + a couple of `-` lists, so a tolerant line-scanner reads
//! it — a `description: |` block scalar's indented body is simply ignored.

use crate::core::Severity;
use crate::scan::Advisory;
use crate::versions::RangeEvent;

/// Tokenize one OR-range's constraints into `(operator, version)` pairs. Handles
/// BOTH the npm form (`>=1.0.0 <2.0.0` — no space in a constraint, space between)
/// AND the RubyGems form (`>= 1.0.0, < 1.5.0` — space after the operator, comma
/// between) with one scanner: read an operator (or none ⇒ `=`), skip optional
/// whitespace, then read the version up to the next separator or operator. All ASCII.
fn parse_constraints(part: &str) -> Vec<(&'static str, String)> {
    let b = part.as_bytes();
    let sep = |c: u8| matches!(c, b' ' | b'\t' | b',');
    let (mut i, mut out) = (0usize, Vec::new());
    while i < b.len() {
        while i < b.len() && sep(b[i]) {
            i += 1;
        }
        if i >= b.len() {
            break;
        }
        let rest = &part[i..];
        let op = if rest.starts_with(">=") {
            i += 2;
            ">="
        } else if rest.starts_with("<=") {
            i += 2;
            "<="
        } else if rest.starts_with('>') {
            i += 1;
            ">"
        } else if rest.starts_with('<') {
            i += 1;
            "<"
        } else if rest.starts_with('=') {
            i += 1;
            "="
        } else {
            "=" // a bare version ⇒ exact
        };
        while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
            i += 1; // whitespace between operator and version (gem style)
        }
        let start = i;
        while i < b.len() && !sep(b[i]) && !matches!(b[i], b'<' | b'>' | b'=') {
            i += 1;
        }
        let ver = part[start..i].trim();
        if !ver.is_empty() {
            // A gemnasium `affected_range` version always starts with a digit. A
            // token that doesn't means an UNSUPPORTED operator leaked into it — a
            // RubyGems `~>` or npm `^`/`~` the tokenizer doesn't model (`~>1.0.0`
            // reads as version `~`). Parsing it to a bogus bound would silently
            // mis-classify versions; instead declare the whole constraint set
            // unparseable (empty) so the caller's guard refuses the advisory rather
            // than fabricate a range.
            if !ver.as_bytes()[0].is_ascii_digit() {
                return Vec::new();
            }
            out.push((op, ver.to_string()));
        }
    }
    out
}

/// Parse a gemnasium **`affected_range`** into modgunn's range disjunction
/// (`Vec<Vec<RangeEvent>>`, one inner list per OR-range). Handles `||` (OR),
/// space- and comma-separated AND-constraints within a range, the operators
/// `>= > <= < =` (and a bare exact version), and anchors an upper-bound-only range
/// with an implicit `Introduced("0")` so it opens (the OSV invariant a hand-built
/// range must uphold). An unparseable range yields an empty disjunction.
pub fn parse_gemnasium_range(range: &str) -> Vec<Vec<RangeEvent>> {
    let mut out = Vec::new();
    for or_part in range.split("||") {
        let (mut events, mut has_lower, mut has_upper) = (Vec::new(), false, false);
        for (op, ver) in parse_constraints(or_part) {
            match op {
                ">=" => {
                    events.push(RangeEvent::Introduced(ver));
                    has_lower = true;
                }
                ">" => {
                    events.push(RangeEvent::IntroducedExclusive(ver));
                    has_lower = true;
                }
                "<" => {
                    events.push(RangeEvent::Fixed(ver));
                    has_upper = true;
                }
                "<=" => {
                    events.push(RangeEvent::LastAffected(ver));
                    has_upper = true;
                }
                "=" => {
                    // Exact: affected only at this version (inclusive both ends).
                    events.push(RangeEvent::Introduced(ver.clone()));
                    events.push(RangeEvent::LastAffected(ver));
                    (has_lower, has_upper) = (true, true);
                }
                _ => {}
            }
        }
        if events.is_empty() {
            continue;
        }
        // Upper-bound-only (`<X`) ⇒ affected from the beginning; anchor it so the
        // range walk actually turns on (else it matches nothing — a false negative).
        if has_upper && !has_lower {
            events.insert(0, RangeEvent::Introduced("0".into()));
        }
        out.push(events);
    }
    out
}

/// A gemnasium `package_slug` (`npm/lodash`, `gem/rack`, `maven/g.roup/artifact`,
/// `packagist/vendor/pkg`) → modgunn's `(ecosystem, package)`. The head is the
/// ecosystem (mapped to modgunn's short key); the rest is the name, with maven's
/// `group/artifact` re-joined as `group:artifact` (the coord OSV/modgunn use).
fn parse_package_slug(slug: &str) -> Option<(&'static str, String)> {
    let (head, rest) = slug.split_once('/')?;
    if rest.is_empty() {
        return None;
    }
    let ecosystem = match head {
        "npm" => "npm",
        "gem" => "gem",
        "pypi" => "pypi",
        "packagist" => "composer",
        "maven" => "maven",
        "nuget" => "nuget",
        "go" => "go",
        "conan" => "conan",
        _ => return None, // an ecosystem modgunn doesn't model
    };
    let name = if ecosystem == "maven" {
        rest.replacen('/', ":", 1)
    } else {
        rest.to_string()
    };
    Some((ecosystem, name))
}

/// Unquote a scanned YAML scalar value (strip surrounding `"`/`'`).
fn unquote(s: &str) -> String {
    let s = s.trim();
    if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
        || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
    {
        s[1..s.len() - 1].to_string()
    } else {
        s.to_string()
    }
}

/// Parse one gemnasium advisory YAML into an [`Advisory`], or `None` if it lacks a
/// package slug / usable coords. A tolerant flat-YAML line-scanner (no `yaml` dep):
/// indent-0 `key: value` scalars + a couple of `-` lists (`identifiers`,
/// `fixed_versions`); an indented `description: |` body is ignored. Severity comes
/// from the `cvss_v3` vector via the shared [`crate::osv`] CVSS calculator, else
/// defaults to `High` (a real advisory is block-worthy).
pub fn parse_gemnasium_yaml(yaml: &str) -> Option<Advisory> {
    use std::collections::HashMap;
    let mut scalars: HashMap<&str, String> = HashMap::new();
    let mut identifiers: Vec<String> = Vec::new();
    let mut current_list: Option<&str> = None;
    const LIST_KEYS: [&str; 3] = ["identifiers", "fixed_versions", "urls"];

    for raw in yaml.lines() {
        let trimmed = raw.trim();
        if trimmed.is_empty() || trimmed == "---" {
            continue;
        }
        // A list item — only consumed while inside a known list block.
        if let Some(item) = trimmed.strip_prefix("- ") {
            if current_list == Some("identifiers") {
                identifiers.push(unquote(item));
            }
            continue;
        }
        // A key line is only recognized at indent 0 (an indented `key:` inside a
        // block scalar is body text, not a field).
        let indented = raw.starts_with([' ', '\t']);
        if indented {
            continue;
        }
        current_list = None;
        let Some((key, val)) = raw.split_once(':') else {
            continue;
        };
        let (key, val) = (key.trim(), val.trim());
        if val.is_empty() && LIST_KEYS.contains(&key) {
            current_list = Some(match key {
                "identifiers" => "identifiers",
                "fixed_versions" => "fixed_versions",
                _ => "urls",
            });
        } else if !val.is_empty() {
            scalars.insert(
                match key {
                    "identifier" => "identifier",
                    "package_slug" => "package_slug",
                    "title" => "title",
                    "description" => "description",
                    "affected_range" => "affected_range",
                    "cvss_v3" => "cvss_v3",
                    "cvss_v2" => "cvss_v2",
                    _ => continue,
                },
                unquote(val),
            );
        }
    }

    let (ecosystem, package) = parse_package_slug(scalars.get("package_slug")?)?;
    // Primary id: `identifier`, else the first `identifiers[]`, else give up.
    let id = scalars
        .get("identifier")
        .cloned()
        .or_else(|| identifiers.first().cloned())?;
    let summary = scalars.get("title").cloned().unwrap_or_default();
    let severity = scalars
        .get("cvss_v3")
        .or_else(|| scalars.get("cvss_v2"))
        .and_then(|v| crate::osv::severity_from_cvss_vector(v))
        .unwrap_or(Severity::High);

    // Parse the affected range. GUARD (the NVD enumerated-CPE lesson): an advisory
    // that lands with empty ranges AND empty affected_versions is read by
    // `Advisory::matches` as "every version affected" — a whole-package false
    // positive. That happens THREE ways: (a) `affected_range` present but
    // unparseable, (b) `affected_range` absent/truncated entirely, (c) a range whose
    // only operator is unsupported (→ parse_gemnasium_range now yields empty). ALL
    // must be refused, so the guard keys on the RESULT (`ranges.is_empty()`), not on
    // whether the raw field was present.
    let raw_range = scalars
        .get("affected_range")
        .map(|s| s.trim())
        .filter(|s| !s.is_empty());
    let ranges = raw_range.map(parse_gemnasium_range).unwrap_or_default();
    if ranges.is_empty() {
        return None; // no usable version bound ⇒ never an all-versions match
    }
    // Aliases = every identifier except the primary id.
    let aliases: Vec<String> = identifiers.into_iter().filter(|i| *i != id).collect();

    let mut adv = Advisory::basic(id, ecosystem, package, Vec::new(), severity, summary)
        .with_source("gitlab")
        .with_range_sets(ranges);
    adv = adv.with_aliases(aliases);
    Some(adv)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::ArtifactRef;

    fn art(eco: &str, name: &str, ver: &str) -> ArtifactRef {
        ArtifactRef {
            ecosystem: eco.into(),
            name: name.into(),
            version: ver.into(),
            blob_sha256: String::new(),
        }
    }

    #[test]
    fn range_bounded_and_open_and_or() {
        // >=1.0.0 <2.0.0 → affected in [1.0.0, 2.0.0)
        let r = parse_gemnasium_range(">=1.0.0 <2.0.0");
        assert_eq!(r.len(), 1);
        assert!(crate::versions::affected_by_any("npm", &r, "1.5.0"));
        assert!(crate::versions::affected_by_any("npm", &r, "1.0.0"));
        assert!(!crate::versions::affected_by_any("npm", &r, "2.0.0"));
        assert!(!crate::versions::affected_by_any("npm", &r, "0.9.0"));
        // <1.0.0 → anchored from 0
        let r = parse_gemnasium_range("<1.0.0");
        assert!(crate::versions::affected_by_any("npm", &r, "0.5.0"));
        assert!(!crate::versions::affected_by_any("npm", &r, "1.0.0"));
        // comma-separated (RubyGems style) `>= 1.0.0, < 1.5.0`
        let r = parse_gemnasium_range(">= 1.0.0, < 1.5.0");
        assert!(crate::versions::affected_by_any("gem", &r, "1.2.0"));
        assert!(!crate::versions::affected_by_any("gem", &r, "1.5.0"));
        // OR ranges: `<1.0.0||>=2.0.0 <2.1.0`
        let r = parse_gemnasium_range("<1.0.0||>=2.0.0 <2.1.0");
        assert_eq!(r.len(), 2);
        assert!(crate::versions::affected_by_any("npm", &r, "0.9.0"));
        assert!(crate::versions::affected_by_any("npm", &r, "2.0.5"));
        assert!(
            !crate::versions::affected_by_any("npm", &r, "1.5.0"),
            "the gap between ranges is clean"
        );
        // exact `=1.2.3`
        let r = parse_gemnasium_range("=1.2.3");
        assert!(crate::versions::affected_by_any("npm", &r, "1.2.3"));
        assert!(!crate::versions::affected_by_any("npm", &r, "1.2.4"));
    }

    #[test]
    fn package_slug_maps_ecosystem_and_maven_coord() {
        assert_eq!(
            parse_package_slug("npm/lodash"),
            Some(("npm", "lodash".into()))
        );
        assert_eq!(
            parse_package_slug("npm/@scope/pkg"),
            Some(("npm", "@scope/pkg".into()))
        );
        assert_eq!(
            parse_package_slug("packagist/vendor/pkg"),
            Some(("composer", "vendor/pkg".into()))
        );
        assert_eq!(
            parse_package_slug("maven/com.foo/bar"),
            Some(("maven", "com.foo:bar".into()))
        );
        assert_eq!(parse_package_slug("gem/rack"), Some(("gem", "rack".into())));
        assert_eq!(parse_package_slug("unknownes/x"), None);
    }

    #[test]
    fn yaml_parses_a_real_shaped_advisory() {
        let yaml = "---\n\
            identifier: \"CVE-2019-10744\"\n\
            identifiers:\n\
            - \"CVE-2019-10744\"\n\
            - \"GHSA-jf85-cpcp-j695\"\n\
            package_slug: \"npm/lodash\"\n\
            title: \"Prototype Pollution\"\n\
            description: |\n\
            \x20 A multi-line description that must be ignored.\n\
            \x20 package_slug: \"not-this\"\n\
            affected_range: \"<4.17.12\"\n\
            fixed_versions:\n\
            - \"4.17.12\"\n\
            cvss_v3: \"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H\"\n";
        let a = parse_gemnasium_yaml(yaml).expect("parses");
        assert_eq!(a.id, "CVE-2019-10744");
        assert_eq!(a.ecosystem, "npm");
        assert_eq!(a.package, "lodash");
        assert_eq!(a.source, "gitlab");
        assert_eq!(
            a.severity,
            Severity::Critical,
            "10.0 CVSS vector, not the High default"
        );
        assert!(a.aliases.iter().any(|x| x == "GHSA-jf85-cpcp-j695"));
        assert!(
            !a.aliases.iter().any(|x| x == "CVE-2019-10744"),
            "primary id not duplicated as alias"
        );
        // the indented `package_slug:` inside the description block was NOT read.
        assert_eq!(a.package, "lodash");
        // matches lodash 4.17.11 (< 4.17.12), not 4.17.12.
        assert!(a.matches(&art("npm", "lodash", "4.17.11")));
        assert!(!a.matches(&art("npm", "lodash", "4.17.12")));
    }

    #[test]
    fn range_exclusive_lower_and_inclusive_upper() {
        // >1.0.0 <=2.0.0 → (1.0.0, 2.0.0]
        let r = parse_gemnasium_range(">1.0.0 <=2.0.0");
        assert!(
            !crate::versions::affected_by_any("npm", &r, "1.0.0"),
            "exclusive lower"
        );
        assert!(crate::versions::affected_by_any("npm", &r, "1.0.1"));
        assert!(
            crate::versions::affected_by_any("npm", &r, "2.0.0"),
            "inclusive upper"
        );
        assert!(!crate::versions::affected_by_any("npm", &r, "2.0.1"));
        // gem form with a space but NO comma: `>= 1.0.0 < 2.0.0`
        let r = parse_gemnasium_range(">= 1.0.0 < 2.0.0");
        assert!(crate::versions::affected_by_any("gem", &r, "1.5.0"));
        assert!(!crate::versions::affected_by_any("gem", &r, "2.0.0"));
    }

    #[test]
    fn unparseable_range_is_skipped_not_all_versions() {
        // An `affected_range` present but yielding no constraints must NOT emit an
        // all-versions advisory (a whole-package false positive).
        // An operator with no version (or only operators) parses to zero constraints.
        assert!(parse_gemnasium_range(">=").is_empty());
        let yaml = "identifier: \"CVE-x\"\npackage_slug: \"npm/foo\"\naffected_range: \">=\"\n";
        assert!(
            parse_gemnasium_yaml(yaml).is_none(),
            "unparseable range ⇒ no all-versions FP"
        );
    }

    #[test]
    fn yaml_without_package_slug_is_none() {
        assert!(parse_gemnasium_yaml("identifier: \"CVE-x\"\ntitle: \"y\"\n").is_none());
    }

    /// RED-when-reverted: a gemnasium advisory with a valid id + slug but NO
    /// `affected_range` at all (an absent/truncated field) must be refused, not
    /// emitted as an all-versions match. The earlier guard only fired when the field
    /// was PRESENT-but-unparseable; an absent field slipped straight through to an
    /// empty-ranges advisory that `Advisory::matches` reads as every-version.
    #[test]
    fn yaml_missing_affected_range_is_refused_not_all_versions() {
        let yaml = "identifier: \"CVE-x\"\npackage_slug: \"npm/foo\"\ntitle: \"no range\"\n";
        assert!(
            parse_gemnasium_yaml(yaml).is_none(),
            "no affected_range ⇒ no all-versions FP"
        );
    }

    /// RED-when-reverted: an unsupported operator (`~>` gem, `^`/`~` npm) must make
    /// the range UNPARSEABLE (→ refused), never a bogus bound. `~>1.0.0` used to
    /// tokenize as version `~` + `>1.0.0`, mis-classifying every version.
    #[test]
    fn unsupported_operators_are_unparseable_not_bogus_bounds() {
        assert!(
            parse_gemnasium_range("~>1.0.0").is_empty(),
            "gem ~> is not modelled ⇒ unparseable"
        );
        assert!(
            parse_gemnasium_range("^1.0.0").is_empty(),
            "npm ^ is not modelled ⇒ unparseable"
        );
        assert!(
            parse_gemnasium_range("~1.2").is_empty(),
            "npm ~ is not modelled ⇒ unparseable"
        );
        // And the supported forms are untouched (no regression).
        assert!(!parse_gemnasium_range(">=1.0.0 <2.0.0").is_empty());
        assert!(!parse_gemnasium_range("=1.2.3").is_empty());
        let yaml =
            "identifier: \"CVE-x\"\npackage_slug: \"gem/rack\"\naffected_range: \"~> 1.0.0\"\n";
        assert!(
            parse_gemnasium_yaml(yaml).is_none(),
            "bogus-operator range ⇒ no all-versions FP"
        );
    }
}