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"
);
}
}