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

mikronova/modgunn

src/sbom.rs

//! CycloneDX 1.5 SBOM **emit** (feature `scan`).
//!
//! modgunn READS SBOMs from the warehouse (`warehouse::read_sbom_components`); this
//! module is the other direction — serializing a resolved component + vuln set into
//! a standards CycloneDX 1.5 document. It owns a small input shape ([`SbomInput`])
//! rather than reaching for a build-side type, so the emit is **multi-ecosystem**
//! from day one: the purl carries the ecosystem (`pkg:cargo/…`, `pkg:npm/…`).
//!
//! nornir's `SecurityReport::to_cyclonedx` reduces to a mapper into [`SbomInput`] +
//! a call here, so the verdict/SBOM serialization lives in exactly one place. For
//! the `cargo` ecosystem the output is byte-compatible with nornir's prior literal.

use serde_json::{json, Value};

/// One component in an SBOM: an ecosystem-qualified package with an optional
/// declared license. `license == None` ⇒ unknown (the `licenses` array is omitted).
pub struct SbomComponent {
    pub ecosystem: String,
    pub name: String,
    pub version: String,
    pub license: Option<String>,
}

/// One matched vulnerability against a component: the advisory ids plus a summary.
pub struct SbomVuln {
    pub ecosystem: String,
    pub name: String,
    pub version: String,
    pub ids: Vec<String>,
    pub summary: String,
}

/// The input to [`to_cyclonedx`]: the SBOM subject (repo / archive name) plus its
/// resolved components and matched vulns.
pub struct SbomInput {
    pub subject: String,
    pub components: Vec<SbomComponent>,
    pub vulns: Vec<SbomVuln>,
}

/// Package URL (purl) for a component: `pkg:{ecosystem}/{name}@{version}`, in the
/// **canonical** percent-encoded form the purl spec requires. The name/version path
/// segments are percent-encoded (RFC 3986 unreserved kept), so a scoped npm package
/// emits `pkg:npm/%40scope/name@…` — NOT the raw `@scope/name`, which a downstream
/// CycloneDX consumer (Grype / Dependency-Track normalize to `%40`) would fail to
/// join to its advisory, a silent false negative on every scoped npm package. The
/// `/` that separates a namespace from the name is preserved (it is a purl
/// separator, not a data character); for `maven` the stored `group:artifact` name's
/// `:` becomes that `/` separator, matching the canonical maven purl.
pub fn purl(ecosystem: &str, name: &str, version: &str) -> String {
    // maven stores `group:artifact`; the canonical purl uses `/` as the
    // namespace/name separator, not `:`.
    let path = if ecosystem == "maven" {
        name.replacen(':', "/", 1)
    } else {
        name.to_string()
    };
    // Encode each `/`-separated segment; the `/` (npm scope, maven group, go module
    // path) is a purl separator and stays.
    let encoded: Vec<String> = path.split('/').map(|seg| purl_encode(seg)).collect();
    format!(
        "pkg:{ecosystem}/{}@{}",
        encoded.join("/"),
        purl_encode(version)
    )
}

/// The **inverse** of [`purl`]: parse `pkg:{type}/{namespace/}{name}@{version}` back
/// into `(ecosystem, name, version)`, undoing the canonical percent-encoding and the
/// maven `group/artifact` → `group:artifact` convention — so the purl grammar lives
/// in exactly ONE module in both directions and a round-trip cannot drift (L5).
///
/// This is what lets a purl-keyed feed join modgunn's `(ecosystem, package)` advisory
/// plane: OSS-Index reports (`coordinates`), CycloneDX SBOMs and the `vuln_findings`
/// key are all purls.
///
/// Handles the full purl shape: the optional `?qualifiers` and `#subpath` suffixes are
/// stripped, `%XX` escapes are decoded (so `pkg:npm/%40scope/name` reads back as the
/// raw `@scope/name`), and a namespace `/` is preserved as part of the name. Returns
/// `None` for anything that is not a `pkg:` URL with a type and a name; the version is
/// `""` when the purl carries none (a version-less purl is a *package*-level coord,
/// which the caller may still want).
pub fn parse_purl(purl: &str) -> Option<(String, String, String)> {
    let rest = purl.trim().strip_prefix("pkg:")?;
    // Drop the qualifiers / subpath — neither participates in the join key.
    let rest = rest.split(['?', '#']).next()?;
    let (ptype, path) = rest.split_once('/')?;
    let ptype = ptype.trim();
    if ptype.is_empty() || path.is_empty() {
        return None;
    }
    // The version is after the LAST `@` — an npm scope's leading `@` lives in the
    // first segment (and is normally `%40`-encoded, but be tolerant of a raw one).
    let (name_path, version) = match path.rfind('@') {
        // A leading `@` at position 0 is a raw npm scope, not a version separator.
        Some(i) if i > 0 => (&path[..i], &path[i + 1..]),
        _ => (path, ""),
    };
    let decoded: Vec<String> = name_path.split('/').map(percent_decode).collect();
    if decoded.iter().all(|s| s.is_empty()) {
        return None;
    }
    let mut name = decoded.join("/");
    // maven's canonical purl uses `/` between group and artifact; modgunn stores
    // `group:artifact` (the coord OSV uses).
    if ptype == "maven" {
        name = name.replacen('/', ":", 1);
    }
    Some((ptype.to_string(), name, percent_decode(version)))
}

/// Decode `%XX` escapes in one purl path segment. A malformed escape is kept
/// verbatim rather than dropped — losing bytes would silently corrupt a package name
/// into one that joins to nothing.
fn percent_decode(seg: &str) -> String {
    let b = seg.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 2 < b.len() {
            let hex = std::str::from_utf8(&b[i + 1..i + 3])
                .ok()
                .and_then(|h| u8::from_str_radix(h, 16).ok());
            if let Some(byte) = hex {
                out.push(byte);
                i += 3;
                continue;
            }
        }
        out.push(b[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Percent-encode one purl path segment per RFC 3986: the unreserved set
/// (`A-Za-z0-9-._~`) is kept, every other byte becomes `%XX` (upper-hex). Operates
/// on bytes so UTF-8 is encoded correctly.
fn purl_encode(seg: &str) -> String {
    let mut out = String::with_capacity(seg.len());
    for b in seg.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char)
            }
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

/// Emit a CycloneDX 1.5 SBOM (components + vulnerabilities) for `input`.
pub fn to_cyclonedx(input: &SbomInput) -> Value {
    json!({
        "bomFormat": "CycloneDX",
        "specVersion": "1.5",
        "version": 1,
        "metadata": { "component": { "type": "application", "name": input.subject } },
        "components": input.components.iter().map(component_json).collect::<Vec<_>>(),
        "vulnerabilities": input.vulns.iter().flat_map(vuln_json).collect::<Vec<_>>(),
    })
}

fn component_json(c: &SbomComponent) -> Value {
    let mut obj = json!({
        "type": "library",
        "name": c.name,
        "version": c.version,
        "purl": purl(&c.ecosystem, &c.name, &c.version),
    });
    // Append licenses last (mirrors nornir's literal key order) when known.
    if let Some(lic) = &c.license {
        obj["licenses"] = json!([{ "license": { "name": lic } }]);
    }
    obj
}

fn vuln_json(v: &SbomVuln) -> Vec<Value> {
    // A matched vuln with no advisory id must NOT be structurally erased (an empty
    // `ids` would make `flat_map` drop the whole vuln) — that is a fail-open. Fall
    // back to a single placeholder id so the finding still appears in the SBOM. The
    // key order (id, source, affects, description) is preserved for nornir byte-compat.
    let ids: Vec<&str> = if v.ids.is_empty() {
        vec!["UNKNOWN"]
    } else {
        v.ids.iter().map(String::as_str).collect()
    };
    ids.iter()
        .map(|id| {
            json!({
                "id": id,
                "source": { "name": "OSV", "url": format!("https://osv.dev/vulnerability/{id}") },
                "affects": [{ "ref": purl(&v.ecosystem, &v.name, &v.version) }],
                "description": v.summary,
            })
        })
        .collect()
}

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

    /// `parse_purl` is the inverse of `purl` — asserted by ROUND-TRIPPING every
    /// awkward shape through both. Reverting either side's percent-encoding or the
    /// maven `group:artifact` ↔ `group/artifact` convention breaks the identity, so
    /// the two can never drift apart into a silent join miss.
    #[test]
    fn purl_parse_is_the_exact_inverse_of_purl_build() {
        let cases = [
            ("cargo", "openssl", "0.10.55"),
            ("npm", "@scope/name", "1.2.3"), // scoped npm ⇒ %40 in the purl
            ("maven", "org.apache.logging.log4j:log4j-core", "2.14.1"), // group:artifact
            ("go", "github.com/foo/bar", "v1.2.3"), // multi-segment module path
            ("pypi", "zope.interface", "5.4.0"),
            ("gem", "rack", "2.2.3"),
            ("npm", "pkg with space", "1.0.0+build.1"), // encoded space AND `+`
        ];
        for (eco, name, ver) in cases {
            let built = purl(eco, name, ver);
            assert_eq!(
                parse_purl(&built),
                Some((eco.to_string(), name.to_string(), ver.to_string())),
                "round-trip failed for {built}"
            );
        }
        // The scoped-npm case really does go through the encoded form.
        assert_eq!(
            purl("npm", "@scope/name", "1.2.3"),
            "pkg:npm/%40scope/name@1.2.3"
        );
        // …and a raw (unencoded) scope from a sloppy producer still parses — the
        // leading `@` is a scope, NOT a version separator.
        assert_eq!(
            parse_purl("pkg:npm/@scope/name@1.2.3"),
            Some(("npm".into(), "@scope/name".into(), "1.2.3".into()))
        );
        // Qualifiers + subpath are stripped; they are not part of the join key.
        assert_eq!(
            parse_purl("pkg:cargo/openssl@0.10.55?repository_url=x#sub/dir"),
            Some(("cargo".into(), "openssl".into(), "0.10.55".into()))
        );
        // A version-less purl is a package-level coord, not a parse failure.
        assert_eq!(
            parse_purl("pkg:npm/lodash"),
            Some(("npm".into(), "lodash".into(), String::new()))
        );
        // Garbage is None — never a bogus coord that joins to the wrong package.
        assert_eq!(parse_purl("openssl@0.10.55"), None);
        assert_eq!(parse_purl("pkg:cargo"), None);
        assert_eq!(parse_purl("pkg:/openssl@1.0"), None);
        assert_eq!(parse_purl(""), None);
        // A malformed escape is preserved verbatim rather than silently dropped.
        assert_eq!(
            parse_purl("pkg:npm/a%ZZb@1.0"),
            Some(("npm".into(), "a%ZZb".into(), "1.0".into()))
        );
    }

    /// The cargo emit is byte-compatible with nornir's prior `to_cyclonedx`
    /// literal: `pkg:cargo/…` purls, a `licenses` array carrying the raw name
    /// (including `NOASSERTION`), and one `vulnerabilities` entry per advisory id.
    #[test]
    fn cyclonedx_cargo_is_byte_compatible() {
        let input = SbomInput {
            subject: "myrepo".into(),
            components: vec![
                SbomComponent {
                    ecosystem: "cargo".into(),
                    name: "serde".into(),
                    version: "1.0.0".into(),
                    license: Some("MIT OR Apache-2.0".into()),
                },
                SbomComponent {
                    ecosystem: "cargo".into(),
                    name: "openssl".into(),
                    version: "0.10.55".into(),
                    license: Some("NOASSERTION".into()),
                },
            ],
            vulns: vec![SbomVuln {
                ecosystem: "cargo".into(),
                name: "openssl".into(),
                version: "0.10.55".into(),
                ids: vec!["RUSTSEC-2023-0044".into()],
                summary: "use-after-free".into(),
            }],
        };
        let want = json!({
            "bomFormat": "CycloneDX",
            "specVersion": "1.5",
            "version": 1,
            "metadata": { "component": { "type": "application", "name": "myrepo" } },
            "components": [
                {
                    "type": "library",
                    "name": "serde",
                    "version": "1.0.0",
                    "purl": "pkg:cargo/serde@1.0.0",
                    "licenses": [{ "license": { "name": "MIT OR Apache-2.0" } }],
                },
                {
                    "type": "library",
                    "name": "openssl",
                    "version": "0.10.55",
                    "purl": "pkg:cargo/openssl@0.10.55",
                    "licenses": [{ "license": { "name": "NOASSERTION" } }],
                },
            ],
            "vulnerabilities": [
                {
                    "id": "RUSTSEC-2023-0044",
                    "source": { "name": "OSV", "url": "https://osv.dev/vulnerability/RUSTSEC-2023-0044" },
                    "affects": [{ "ref": "pkg:cargo/openssl@0.10.55" }],
                    "description": "use-after-free",
                },
            ],
        });
        assert_eq!(to_cyclonedx(&input), want);
    }

    /// The purl carries the ecosystem, so a non-cargo component emits `pkg:npm/…`,
    /// and an unknown license omits the `licenses` array entirely.
    #[test]
    fn cyclonedx_multi_ecosystem_purl_and_unknown_license() {
        let input = SbomInput {
            subject: "app".into(),
            components: vec![SbomComponent {
                ecosystem: "npm".into(),
                name: "left-pad".into(),
                version: "1.3.0".into(),
                license: None,
            }],
            vulns: vec![],
        };
        let out = to_cyclonedx(&input);
        let comp = &out["components"][0];
        assert_eq!(comp["purl"], "pkg:npm/left-pad@1.3.0");
        assert!(
            comp.get("licenses").is_none(),
            "unknown license ⇒ no licenses array"
        );
    }

    /// RED-when-reverted: a scoped npm package's purl MUST percent-encode the scope
    /// `@` → `%40` (canonical purl), else a normalizing consumer won't join the
    /// advisory. The namespace `/` is preserved. Maven's stored `group:artifact`
    /// becomes the canonical `group/artifact` namespace form.
    #[test]
    fn purl_is_canonical_percent_encoded() {
        assert_eq!(
            purl("npm", "@angular/core", "17.0.0"),
            "pkg:npm/%40angular/core@17.0.0"
        );
        assert_eq!(
            purl("maven", "org.apache.logging.log4j:log4j-core", "2.14.1"),
            "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
        );
        // A go module path keeps its `/` separators (they are purl separators).
        assert_eq!(
            purl("go", "github.com/pkg/errors", "v0.9.1"),
            "pkg:go/github.com/pkg/errors@v0.9.1"
        );
        // Plain names/versions with only unreserved chars are unchanged (cargo compat).
        assert_eq!(purl("cargo", "serde", "1.0.0"), "pkg:cargo/serde@1.0.0");
        // The scoped ref reaches the vuln `affects` too, not just the component.
        let input = SbomInput {
            subject: "app".into(),
            components: vec![],
            vulns: vec![SbomVuln {
                ecosystem: "npm".into(),
                name: "@babel/traverse".into(),
                version: "7.23.0".into(),
                ids: vec!["CVE-2023-45133".into()],
                summary: "arbitrary code execution".into(),
            }],
        };
        let out = to_cyclonedx(&input);
        assert_eq!(
            out["vulnerabilities"][0]["affects"][0]["ref"],
            "pkg:npm/%40babel/traverse@7.23.0"
        );
    }

    /// RED-when-reverted: a matched vuln that carries no advisory id must still emit
    /// an SBOM entry (was silently dropped by `flat_map` over an empty `ids`).
    #[test]
    fn idless_vuln_is_not_dropped() {
        let input = SbomInput {
            subject: "app".into(),
            components: vec![],
            vulns: vec![SbomVuln {
                ecosystem: "npm".into(),
                name: "left-pad".into(),
                version: "1.3.0".into(),
                ids: vec![],
                summary: "matched, id pending".into(),
            }],
        };
        let out = to_cyclonedx(&input);
        assert_eq!(
            out["vulnerabilities"].as_array().unwrap().len(),
            1,
            "id-less vuln preserved"
        );
        assert_eq!(
            out["vulnerabilities"][0]["description"],
            "matched, id pending"
        );
    }
}