Developer tools

Software bills of materials

CycloneDX JSON and SPDX, JSON or tag-value, read into one shape and built from a list of components. Package URLs parsed and made canonical by the purl rules, licences counted and held against an allow list with OR and AND understood, why a package is there, cycles and orphans in the graph, and what changed between two releases across formats.

Version 1.0.0 · 10 functions · 1 crate

#security#supply-chain#licences#dependencies

Functions

What use sbom.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use sbom_purl_parse, sbom_purl_make from sbom (Quillo 0.6).

sbom_purl_parse(purl: str) → anyor an error

Parses a package URL: { type, namespace, name, version, qualifiers, subpath }, names normalised as each ecosystem says.

sbom_purl_make(parts: any) → stror an error

Makes the canonical package URL from { type, namespace, name, version, qualifiers, subpath }.

sbom_read(text: str) → anyor an error

Reads CycloneDX JSON, or SPDX as JSON or tag-value, into one shape: { format, spec_version, name, version, components, dependencies, problems }.

sbom_read_file(path: str) → anyor an error

Reads a bill of materials from a path, as sbom_read does.

sbom_licenses(text: str, allow: list) → anyor an error

Licences and how many components have each, and which components have none; allow, when not empty, lists those outside it.

sbom_why(text: str, name: str) → listor an error

Why a package is here: the paths from the root to each component whose name or ref matches, shortest first, up to 20.

sbom_graph(text: str) → anyor an error

The graph looked at: direct and transitive counts, components nothing reaches, and cycles.

sbom_diff(before: str, after: str) → anyor an error

What changed between two: packages added, removed and changed, by name, whichever formats the two are in.

sbom_cyclonedx(root: any, components: list, serial: str) → stror an error

Builds CycloneDX 1.5 JSON. root is { name, version }; a component is { name, version, purl, licenses, group, type, depends_on }, purl a string or parts, depends_on other components' names. serial is a UUID the caller keeps, so the output is repeatable.

sbom_spdx(root: any, components: list, namespace: str, created: str) → stror an error

Builds SPDX 2.3 JSON from the same input as sbom_cyclonedx. created is a time like 2026-11-01T09:00:00Z, namespace the document's unique URI.

Get it

$ quillo install sbom               # sbom.qwer into this folder$ quillo install sbom --example     # and its example, sbom.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/sbom.qwer$ curl -O https://quillolang.org/plugins/files/sbom.qwe$ curl -O https://quillolang.org/plugins/files/sbom_sample.cdx.json$ curl -O https://quillolang.org/plugins/files/sbom_sample.spdx

Then, in your program, all of it or only what you call:

main.qwe
use sbom.qweruse sbom_purl_parse, sbom_purl_make from sbom      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }

Example

sbom.qwe
// A release's bill of materials: read from CycloneDX or SPDX, its licences checked, why a package// is there, what changed since the last one, and one built for a program's own dependencies.////   quillo run sbom.qwe      then open http://localhost:8080/read, /spdx, /licenses, /why, /graph, /diff, /purl, /build and /brokenuse "sbom.qwer"let CDX = read_file("sbom_sample.cdx.json")let SPDX = read_file("sbom_sample.spdx")route GET /read {    let b = sbom_read_file("sbom_sample.cdx.json")?    { format: b.format, spec: b.spec_version, name: b.name, version: b.version, components: b.components | map((c) => `${c.name}@${c.version}`), first: b.components[0], problems: b.problems, deps: len(b.dependencies) }}route GET /spdx {    let b = sbom_read(SPDX)?    { format: b.format, spec: b.spec_version, name: b.name, version: b.version, parts: b.components | map((c) => [c.name, c.version, c.licenses, c.purl]), supplier: b.components[1].supplier, hash: b.components[1].hashes, deps: b.dependencies, problems: b.problems }}route GET /licenses = {    all: sbom_licenses(CDX, [])?,    policy: sbom_licenses(CDX, ["MIT", "Apache-2.0"])?.not_allowed,}route GET /why = {    qs: sbom_why(CDX, "qs")?,    spdx: sbom_why(SPDX, "left-pad")?,    stranger: sbom_why(CDX, "react").error,}route GET /graph = sbom_graph(CDX)?route GET /diff = sbom_diff(CDX, SPDX)?route GET /purl = {    scoped: sbom_purl_parse("pkg:npm/%40angular/core@17.0.1")?,    qualified: sbom_purl_parse("pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie")?,    pypi: sbom_purl_parse("pkg:PYPI/Django_Rest@1.0#src/./lib")?,    made: sbom_purl_make({ type: "npm", namespace: "@angular", name: "core", version: "17.0.1" })?,    maven: sbom_purl_make({ type: "maven", namespace: "org.apache.xmlgraphics", name: "batik-anim", version: "1.9.1", qualifiers: { repository_url: "repo.spring.io/release", classifier: "sources" } })?,    image: sbom_purl_make({ type: "docker", name: "cassandra", version: "sha256:244fd47e07d1004f" })?,    plus: sbom_purl_make({ type: "gem", name: "a b", version: "1.0+x", subpath: "lib/../x" })?,    again: sbom_purl_make(sbom_purl_parse("pkg:golang/GitHub.com/Gorilla/Context@1.1.1#a/b")?)?,    bad: [sbom_purl_parse("npm/x").error, sbom_purl_parse("pkg:npm").error, sbom_purl_parse("pkg:npm/%zz").error, sbom_purl_make({ name: "x" }).error],}let ROOT = { name: "billing", version: "1.0.0" }let PARTS = [    { name: "ureq", version: "2.10.0", purl: { type: "cargo", name: "ureq", version: "2.10.0" }, licenses: ["MIT OR Apache-2.0"], depends_on: ["ring"] },    { name: "ring", version: "0.17.8", purl: "pkg:cargo/ring@0.17.8", licenses: ["ISC"], direct: false },]route GET /build {    let cdx = sbom_cyclonedx(ROOT, PARTS, "3E671687-395B-41F5-A30F-A58921A69B79")?    let spdx = sbom_spdx(ROOT, PARTS, "https://example.org/spdx/billing-1", "2026-11-01T09:00:00Z")?    let a = sbom_read(cdx)?    let b = sbom_read(spdx)?    {        serial: json.parse(cdx).serialNumber,        cdx: [a.spec_version, a.name, a.problems, sbom_why(cdx, "ring")?],        expression: json.parse(cdx).components[0].licenses,        spdx: [b.spec_version, b.name, b.problems, sbom_why(spdx, "ring")?],        same: sbom_diff(cdx, spdx)?,        unknown_dep: sbom_cyclonedx(ROOT, [{ name: "a", depends_on: ["b"] }], "3E671687-395B-41F5-A30F-A58921A69B79").error,        bad_serial: sbom_cyclonedx(ROOT, [], "nope").error,    }}route GET /broken = {    neither: sbom_read("{}").error,    xml: sbom_read("<bom/>").error,    no_root: sbom_why("{\"bomFormat\":\"CycloneDX\",\"components\":[{\"name\":\"x\",\"bom-ref\":\"x\"}]}", "x").error,    dangling: sbom_read("{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\",\"components\":[{\"name\":\"x\",\"bom-ref\":\"x\",\"purl\":\"pkg:npm\"}],\"dependencies\":[{\"ref\":\"x\",\"dependsOn\":[\"gone\"]}]}")?.problems,}
$ quillo run sbom.qwe$ curl 'localhost:8080/read'$ curl 'localhost:8080/spdx'$ curl 'localhost:8080/licenses'$ curl 'localhost:8080/why'$ curl 'localhost:8080/graph'$ curl 'localhost:8080/diff'$ curl 'localhost:8080/purl'$ curl 'localhost:8080/build'$ curl 'localhost:8080/broken'

Source

sbom.qwer
//! CycloneDX and SPDX software bills of materials read and built: components, package URLs parsed and made, licences, the dependency graph and why a package is there, and what changed between two.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};// Package URLs, by the purl specification.fn hex_val(b: u8) -> Option<u8> {    (b as char).to_digit(16).map(|d| d as u8)}fn pct_decode(s: &str) -> Result<String, String> {    let b = s.as_bytes();    let mut out = Vec::with_capacity(b.len());    let mut i = 0;    while i < b.len() {        if b[i] == b'%' {            match (b.get(i + 1).copied().and_then(hex_val), b.get(i + 2).copied().and_then(hex_val)) {                (Some(h), Some(l)) => out.push(h * 16 + l),                _ => return Err(format!("`{}` has a bad percent escape", s)),            }            i += 3;        } else {            out.push(b[i]);            i += 1;        }    }    String::from_utf8(out).map_err(|_| format!("`{}` is not UTF-8 once decoded", s))}fn pct_encode(s: &str, keep: &str) -> String {    let mut out = String::new();    for b in s.bytes() {        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) || keep.as_bytes().contains(&b) {            out.push(b as char);        } else {            out.push_str(&format!("%{:02X}", b));        }    }    out}/// Each ecosystem's own rule for what counts as the same name.fn normal_name(kind: &str, name: &str) -> String {    match kind {        "pypi" => name.to_lowercase().replace('_', "-"),        "github" | "bitbucket" | "golang" | "composer" | "npm" | "hex" | "deb" | "apk" | "alpm" | "bitnami" | "gitlab" => name.to_lowercase(),        _ => name.to_string(),    }}fn normal_namespace(kind: &str, ns: &str) -> String {    match kind {        "github" | "bitbucket" | "golang" | "composer" | "npm" | "deb" | "apk" | "alpm" | "rpm" | "gitlab" => ns.to_lowercase(),        _ => ns.to_string(),    }}fn parse_purl(text: &str) -> Result<Value, String> {    let rest = text.trim();    let head = rest.get(..4).unwrap_or("");    if !head.eq_ignore_ascii_case("pkg:") {        return Err(format!("`{}` is not a package URL: it must start pkg:", text));    }    let rest = rest[4..].trim_start_matches('/');    let (rest, subpath) = match rest.rsplit_once('#') {        Some((a, b)) => (a, Some(b)),        None => (rest, None),    };    let (rest, query) = match rest.rsplit_once('?') {        Some((a, b)) => (a, Some(b)),        None => (rest, None),    };    let (kind, rest) = rest.split_once('/').ok_or(format!("`{}` has no type/name", text))?;    let kind = kind.to_lowercase();    if kind.is_empty() || kind.as_bytes()[0].is_ascii_digit() || !kind.bytes().all(|b| b.is_ascii_alphanumeric() || b"+-.".contains(&b)) {        return Err(format!("`{}` is not a package type", kind));    }    let rest = rest.trim_matches('/');    // The version follows the last `@` after the last slash; a `%40` scope is safe from it.    let tail_at = rest.rfind('/').map_or(0, |i| i + 1);    let (path, version) = match rest[tail_at..].rfind('@') {        Some(i) => (&rest[..tail_at + i], Some(&rest[tail_at + i + 1..])),        None => (rest, None),    };    let mut pieces: Vec<String> = vec![];    for p in path.split('/').filter(|p| !p.is_empty()) {        pieces.push(pct_decode(p)?);    }    let name = pieces.pop().filter(|n| !n.is_empty()).ok_or(format!("`{}` has no name", text))?;    let namespace = pieces.join("/");    let mut quals = BTreeMap::new();    for q in query.unwrap_or("").split('&').filter(|q| !q.is_empty()) {        let (k, v) = q.split_once('=').ok_or(format!("the qualifier `{}` has no value", q))?;        let k = k.to_lowercase();        if k.is_empty() || k.as_bytes()[0].is_ascii_digit() || !k.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) {            return Err(format!("`{}` is not a qualifier name", k));        }        let v = pct_decode(v)?;        if !v.is_empty() {            quals.insert(k, v);        }    }    let subpath = match subpath {        Some(s) => {            let mut parts = vec![];            for p in s.split('/').filter(|p| !p.is_empty() && *p != "." && *p != "..") {                parts.push(pct_decode(p)?);            }            parts.join("/")        }        None => String::new(),    };    let version = match version.filter(|v| !v.is_empty()) {        Some(v) => Some(pct_decode(v)?),        None => None,    };    let ns = normal_namespace(&kind, &namespace);    let name = normal_name(&kind, &name);    Ok(json!({ "type": kind, "namespace": if ns.is_empty() { Value::Null } else { json!(ns) }, "name": name, "version": version, "qualifiers": quals, "subpath": if subpath.is_empty() { Value::Null } else { json!(subpath) } }))}fn make_purl(p: &Value) -> Result<String, String> {    let kind = p.get("type").and_then(Value::as_str).filter(|k| !k.is_empty()).ok_or("a package URL needs a `type`")?.to_lowercase();    let name = p.get("name").and_then(Value::as_str).filter(|k| !k.is_empty()).ok_or("a package URL needs a `name`")?;    let name = normal_name(&kind, name);    let mut out = format!("pkg:{}/", kind);    if let Some(ns) = p.get("namespace").and_then(Value::as_str).filter(|n| !n.is_empty()) {        let ns = normal_namespace(&kind, ns);        let segs: Vec<String> = ns.split('/').filter(|s| !s.is_empty()).map(|s| pct_encode(s, "")).collect();        out.push_str(&segs.join("/"));        out.push('/');    }    out.push_str(&pct_encode(&name, ""));    if let Some(v) = p.get("version").and_then(Value::as_str).filter(|v| !v.is_empty()) {        out.push('@');        out.push_str(&pct_encode(v, ":"));    }    if let Some(q) = p.get("qualifiers").and_then(Value::as_object) {        let mut keys: Vec<(String, String)> = q.iter().filter_map(|(k, v)| v.as_str().filter(|v| !v.is_empty()).map(|v| (k.to_lowercase(), v.to_string()))).collect();        keys.sort();        let parts: Vec<String> = keys.into_iter().map(|(k, v)| format!("{}={}", k, pct_encode(&v, ":/"))).collect();        if !parts.is_empty() {            out.push('?');            out.push_str(&parts.join("&"));        }    }    if let Some(s) = p.get("subpath").and_then(Value::as_str).filter(|s| !s.is_empty()) {        let segs: Vec<String> = s.split('/').filter(|p| !p.is_empty() && *p != "." && *p != "..").map(|p| pct_encode(p, "")).collect();        if !segs.is_empty() {            out.push('#');            out.push_str(&segs.join("/"));        }    }    Ok(out)}/// Parses a package URL: `{ type, namespace, name, version, qualifiers, subpath }`, names normalised as each ecosystem says.pub fn sbom_purl_parse(purl: &str) -> Result<Value, String> {    parse_purl(purl)}/// Makes the canonical package URL from `{ type, namespace, name, version, qualifiers, subpath }`.pub fn sbom_purl_make(parts: serde_json::Value) -> Result<String, String> {    make_purl(&parts)}// Reading.struct Bom {    format: String,    spec: String,    name: Option<String>,    version: Option<String>,    root: Option<String>,    components: Vec<Value>,    deps: BTreeMap<String, Vec<String>>,}fn s<'a>(v: &'a Value, k: &str) -> Option<&'a str> {    v.get(k).and_then(Value::as_str)}fn no_assertion(v: Option<&str>) -> Option<String> {    v.filter(|x| !x.is_empty() && *x != "NOASSERTION" && *x != "NONE").map(str::to_string)}fn cdx_licences(c: &Value) -> Vec<String> {    c.get("licenses").and_then(Value::as_array).into_iter().flatten().filter_map(|l| {        s(l, "expression").map(str::to_string).or_else(|| {            let x = l.get("license")?;            s(x, "id").or(s(x, "name")).map(str::to_string)        })    }).collect()}fn cdx_walk(list: &[Value], out: &mut Vec<Value>, depth: usize) {    if depth > 64 {        return;    }    for c in list {        out.push(cdx_component(c));        if let Some(k) = c.get("components").and_then(Value::as_array) {            cdx_walk(k, out, depth + 1);        }    }}fn cdx_component(c: &Value) -> Value {    let group = s(c, "group").filter(|g| !g.is_empty());    let name = s(c, "name").unwrap_or("");    let hashes: Map<String, Value> = c.get("hashes").and_then(Value::as_array).into_iter().flatten().filter_map(|h| Some((s(h, "alg")?.to_string(), json!(s(h, "content")?)))).collect();    json!({        "ref": s(c, "bom-ref").or(s(c, "purl")).unwrap_or(name),        "name": group.map_or(name.to_string(), |g| format!("{}/{}", g, name)),        "version": s(c, "version"), "type": s(c, "type"), "purl": s(c, "purl"),        "licenses": cdx_licences(c), "hashes": hashes, "supplier": c.get("supplier").and_then(|x| s(x, "name")).or(s(c, "publisher")),        "scope": s(c, "scope"),    })}fn read_cyclonedx(v: &Value) -> Bom {    let spec = s(v, "specVersion").unwrap_or("").to_string();    let meta = v.get("metadata").and_then(|m| m.get("component"));    let mut components = vec![];    cdx_walk(v.get("components").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), &mut components, 0);    let mut deps = BTreeMap::new();    for d in v.get("dependencies").and_then(Value::as_array).into_iter().flatten() {        if let Some(r) = s(d, "ref") {            deps.insert(r.to_string(), d.get("dependsOn").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect());        }    }    Bom {        format: "cyclonedx".into(), spec,        name: meta.and_then(|m| s(m, "name")).map(str::to_string),        version: meta.and_then(|m| s(m, "version")).map(str::to_string),        root: meta.and_then(|m| s(m, "bom-ref").or(s(m, "purl")).or(s(m, "name"))).map(str::to_string),        components, deps,    }}fn spdx_add_relation(deps: &mut BTreeMap<String, Vec<String>>, roots: &mut Vec<String>, from: &str, kind: &str, to: &str) {    let (a, b) = match kind {        "DEPENDS_ON" | "CONTAINS" | "STATIC_LINK" | "DYNAMIC_LINK" | "HAS_PREREQUISITE" => (from, to),        "DEPENDENCY_OF" | "CONTAINED_BY" | "PREREQUISITE_FOR" => (to, from),        "DESCRIBES" => {            roots.push(to.to_string());            return;        }        "DESCRIBED_BY" => {            roots.push(from.to_string());            return;        }        _ => return,    };    let e = deps.entry(a.to_string()).or_default();    if !e.iter().any(|x| x == b) {        e.push(b.to_string());    }}fn spdx_licences(concluded: Option<&str>, declared: Option<&str>) -> Vec<String> {    no_assertion(concluded).or(no_assertion(declared)).into_iter().collect()}fn read_spdx_json(v: &Value) -> Bom {    let mut components = vec![];    for p in v.get("packages").and_then(Value::as_array).into_iter().flatten() {        let purl = p.get("externalRefs").and_then(Value::as_array).into_iter().flatten().find(|r| s(r, "referenceType") == Some("purl")).and_then(|r| s(r, "referenceLocator"));        let hashes: Map<String, Value> = p.get("checksums").and_then(Value::as_array).into_iter().flatten().filter_map(|h| Some((s(h, "algorithm")?.to_string(), json!(s(h, "checksumValue")?)))).collect();        components.push(json!({            "ref": s(p, "SPDXID"), "name": s(p, "name"), "version": s(p, "versionInfo"), "type": s(p, "primaryPackagePurpose").map(str::to_lowercase), "purl": purl,            "licenses": spdx_licences(s(p, "licenseConcluded"), s(p, "licenseDeclared")), "hashes": hashes,            "supplier": no_assertion(s(p, "supplier")).map(|x| x.trim_start_matches("Organization: ").trim_start_matches("Person: ").to_string()), "scope": null,        }));    }    let mut deps = BTreeMap::new();    let mut roots = vec![];    for r in v.get("relationships").and_then(Value::as_array).into_iter().flatten() {        if let (Some(a), Some(k), Some(b)) = (s(r, "spdxElementId"), s(r, "relationshipType"), s(r, "relatedSpdxElement")) {            spdx_add_relation(&mut deps, &mut roots, a, k, b);        }    }    for r in v.get("documentDescribes").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {        roots.push(r.to_string());    }    let root = roots.first().cloned();    let meta = root.as_ref().and_then(|r| components.iter().find(|c| s(c, "ref") == Some(r)));    Bom {        format: "spdx".into(), spec: s(v, "spdxVersion").unwrap_or("").trim_start_matches("SPDX-").to_string(),        name: meta.and_then(|m| s(m, "name")).or(s(v, "name")).map(str::to_string),        version: meta.and_then(|m| s(m, "version")).map(str::to_string),        root, components, deps,    }}fn read_spdx_tags(text: &str) -> Bom {    let mut components: Vec<Value> = vec![];    let mut deps = BTreeMap::new();    let mut roots = vec![];    let (mut spec, mut doc) = (String::new(), None);    let mut lines = text.lines().peekable();    while let Some(line) = lines.next() {        let line = line.trim();        if line.starts_with('#') {            continue;        }        let Some((k, mut v)) = line.split_once(':').map(|(k, v)| (k.trim(), v.trim().to_string())) else { continue };        // <text> ... </text> runs over lines.        if v.starts_with("<text>") && !v.contains("</text>") {            for l in lines.by_ref() {                v.push('\n');                v.push_str(l);                if l.contains("</text>") {                    break;                }            }        }        match k {            "SPDXVersion" => spec = v.trim_start_matches("SPDX-").to_string(),            "DocumentName" => doc = Some(v),            "PackageName" => components.push(json!({ "ref": null, "name": v, "version": null, "type": null, "purl": null, "licenses": [], "hashes": {}, "supplier": null, "scope": null, "declared": null })),            "SPDXID" => if let Some(c) = components.last_mut() { if c["ref"].is_null() { c["ref"] = json!(v); } },            "PackageVersion" => if let Some(c) = components.last_mut() { c["version"] = json!(v); },            "PackageSupplier" => if let Some(c) = components.last_mut() { c["supplier"] = json!(no_assertion(Some(&v)).map(|x| x.trim_start_matches("Organization: ").trim_start_matches("Person: ").to_string())); },            "PackageLicenseConcluded" => if let Some(c) = components.last_mut() { c["licenses"] = json!(no_assertion(Some(&v)).into_iter().collect::<Vec<_>>()); },            "PackageLicenseDeclared" => if let Some(c) = components.last_mut() { if c["licenses"].as_array().map_or(true, Vec::is_empty) { c["licenses"] = json!(no_assertion(Some(&v)).into_iter().collect::<Vec<_>>()); } },            "PackageChecksum" => if let (Some(c), Some((a, h))) = (components.last_mut(), v.split_once(':')) { c["hashes"][a.trim()] = json!(h.trim()); },            "ExternalRef" => {                let p: Vec<&str> = v.split_whitespace().collect();                if p.len() >= 3 && p[1] == "purl" {                    if let Some(c) = components.last_mut() { c["purl"] = json!(p[2]); }                }            }            "Relationship" => {                let p: Vec<&str> = v.split_whitespace().collect();                if p.len() == 3 {                    spdx_add_relation(&mut deps, &mut roots, p[0], p[1], p[2]);                }            }            _ => {}        }    }    for c in components.iter_mut() {        if let Some(o) = c.as_object_mut() {            o.remove("declared");        }    }    let root = roots.first().cloned();    let meta = root.as_ref().and_then(|r| components.iter().find(|c| s(c, "ref") == Some(r))).cloned();    Bom {        format: "spdx".into(), spec,        name: meta.as_ref().and_then(|m| s(m, "name")).map(str::to_string).or(doc),        version: meta.as_ref().and_then(|m| s(m, "version")).map(str::to_string),        root, components, deps,    }}fn load(text: &str) -> Result<Bom, String> {    let t = text.trim_start_matches('\u{feff}').trim_start();    if t.starts_with("SPDXVersion") || t.starts_with("##") || t.starts_with('#') && t.contains("SPDXVersion:") {        return Ok(read_spdx_tags(t));    }    if t.starts_with('<') {        return Err("CycloneDX XML is not read here: ask the tool for JSON".to_string());    }    let v: Value = serde_json::from_str(t).map_err(|e| format!("not JSON: {}", e))?;    if s(&v, "bomFormat") == Some("CycloneDX") {        Ok(read_cyclonedx(&v))    } else if v.get("spdxVersion").is_some() {        Ok(read_spdx_json(&v))    } else {        Err("neither CycloneDX (no `bomFormat`) nor SPDX (no `spdxVersion`)".to_string())    }}fn label(b: &Bom, r: &str) -> String {    match b.components.iter().find(|c| s(c, "ref") == Some(r)) {        Some(c) => match s(c, "version") {            Some(v) => format!("{}@{}", s(c, "name").unwrap_or(r), v),            None => s(c, "name").unwrap_or(r).to_string(),        },        None if Some(r) == b.root.as_deref() => b.name.clone().unwrap_or(r.to_string()),        None => r.to_string(),    }}fn problems_of(b: &Bom) -> Vec<String> {    let mut out = vec![];    let known: HashSet<&str> = b.components.iter().filter_map(|c| s(c, "ref")).chain(b.root.as_deref()).collect();    let mut refs = HashSet::new();    for c in &b.components {        let id = label(b, s(c, "ref").unwrap_or(""));        if let Some(r) = s(c, "ref") {            if !refs.insert(r.to_string()) {                out.push(format!("{}: the reference {} is used twice", id, r));            }        }        if c["version"].is_null() {            out.push(format!("{}: no version", id));        }        match s(c, "purl") {            None => out.push(format!("{}: no package URL", id)),            Some(p) => if let Err(e) = parse_purl(p) { out.push(format!("{}: {}", id, e)); },        }        if c["licenses"].as_array().map_or(true, Vec::is_empty) {            out.push(format!("{}: no licence", id));        }    }    for (from, to) in &b.deps {        for t in to.iter().chain(std::iter::once(from)) {            if !known.contains(t.as_str()) && !t.starts_with("SPDXRef-DOCUMENT") {                out.push(format!("the graph names {}, which is not a component", t));            }        }    }    out.sort();    out.dedup();    out}/// Reads CycloneDX JSON, or SPDX as JSON or tag-value, into one shape: `{ format, spec_version, name, version, components, dependencies, problems }`.pub fn sbom_read(text: &str) -> Result<Value, String> {    let b = load(text)?;    let problems = problems_of(&b);    Ok(json!({ "format": b.format, "spec_version": b.spec, "name": b.name, "version": b.version, "root": b.root, "components": b.components, "dependencies": b.deps, "problems": problems }))}/// Reads a bill of materials from a path, as `sbom_read` does.pub fn sbom_read_file(path: &str) -> Result<Value, String> {    let t = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {}", path, e))?;    sbom_read(&t)}/// Licences and how many components have each, and which components have none; `allow`, when not empty, lists those outside it.pub fn sbom_licenses(text: &str, allow: Vec<String>) -> Result<Value, String> {    let b = load(text)?;    let mut counts: BTreeMap<String, i64> = BTreeMap::new();    let mut none = vec![];    let mut denied = vec![];    for c in &b.components {        let id = label(&b, s(c, "ref").unwrap_or(""));        let list = c["licenses"].as_array().cloned().unwrap_or_default();        if list.is_empty() {            none.push(id.clone());        }        for l in list.iter().filter_map(Value::as_str) {            *counts.entry(l.to_string()).or_default() += 1;            if !allow.is_empty() && !expression_allowed(l, &allow) {                denied.push(json!({ "component": id, "license": l }));            }        }    }    Ok(json!({ "licenses": counts, "unknown": none, "not_allowed": denied }))}/// `MIT OR GPL-3.0` passes when either side does; `A AND B` needs both.fn expression_allowed(e: &str, allow: &[String]) -> bool {    let t = e.trim().trim_matches(|c| c == '(' || c == ')');    t.split(" OR ").any(|side| side.split(" AND ").all(|one| {        let one = one.trim().trim_matches(|c| c == '(' || c == ')');        allow.iter().any(|a| a.eq_ignore_ascii_case(one))    }))}/// Why a package is here: the paths from the root to each component whose name or ref matches, shortest first, up to 20.pub fn sbom_why(text: &str, name: &str) -> Result<Vec<Vec<String>>, String> {    let b = load(text)?;    let wanted: Vec<String> = b.components.iter().filter(|c| s(c, "name") == Some(name) || s(c, "ref") == Some(name) || s(c, "purl") == Some(name)).filter_map(|c| s(c, "ref").map(str::to_string)).collect();    if wanted.is_empty() {        return Err(format!("{} is not in this bill of materials", name));    }    let root = b.root.clone().ok_or("this bill of materials names no root component")?;    let mut found = vec![];    let mut queue: VecDeque<Vec<String>> = VecDeque::from([vec![root]]);    let mut steps = 0;    while let Some(path) = queue.pop_front() {        steps += 1;        if steps > 200_000 || found.len() >= 20 {            break;        }        let last = path.last().unwrap();        if wanted.contains(last) {            found.push(path.iter().map(|r| label(&b, r)).collect());            continue;        }        for n in b.deps.get(last).into_iter().flatten() {            if !path.contains(n) {                let mut p = path.clone();                p.push(n.clone());                queue.push_back(p);            }        }    }    Ok(found)}/// The graph looked at: direct and transitive counts, components nothing reaches, and cycles.pub fn sbom_graph(text: &str) -> Result<Value, String> {    let b = load(text)?;    let root = b.root.clone();    let direct = root.as_ref().and_then(|r| b.deps.get(r)).map_or(vec![], |d| d.iter().map(|x| label(&b, x)).collect::<Vec<_>>());    let mut seen: HashSet<String> = HashSet::new();    let mut stack: Vec<String> = root.iter().cloned().collect();    while let Some(n) = stack.pop() {        if seen.insert(n.clone()) {            stack.extend(b.deps.get(&n).into_iter().flatten().cloned());        }    }    let orphans: Vec<String> = b.components.iter().filter_map(|c| s(c, "ref")).filter(|r| !seen.contains(*r)).map(|r| label(&b, r)).collect();    // Depth-first with three colours: an edge back to a grey node closes a cycle.    let mut colour: HashMap<String, u8> = HashMap::new();    let mut cycles = vec![];    fn visit(n: &str, b: &Bom, colour: &mut HashMap<String, u8>, path: &mut Vec<String>, cycles: &mut Vec<Vec<String>>, depth: usize) {        if depth > 2000 {            return;        }        colour.insert(n.to_string(), 1);        path.push(n.to_string());        for m in b.deps.get(n).into_iter().flatten() {            match colour.get(m.as_str()) {                Some(1) => {                    let at = path.iter().position(|x| x == m).unwrap_or(0);                    let mut c: Vec<String> = path[at..].iter().map(|r| label(b, r)).collect();                    c.push(label(b, m));                    cycles.push(c);                }                Some(_) => {}                None => visit(m, b, colour, path, cycles, depth + 1),            }        }        path.pop();        colour.insert(n.to_string(), 2);    }    let starts: Vec<String> = b.root.iter().cloned().chain(b.deps.keys().cloned()).collect();    for k in starts {        if !colour.contains_key(&k) {            visit(&k, &b, &mut colour, &mut vec![], &mut cycles, 0);        }    }    Ok(json!({ "direct": direct, "reachable": seen.len().saturating_sub(1), "orphans": orphans, "cycles": cycles }))}/// What changed between two: packages `added`, `removed` and `changed`, by name, whichever formats the two are in.pub fn sbom_diff(before: &str, after: &str) -> Result<Value, String> {    let (a, b) = (load(before).map_err(|e| format!("before: {}", e))?, load(after).map_err(|e| format!("after: {}", e))?);    let vers = |x: &Bom| {        let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();        for c in &x.components {            m.entry(s(c, "name").unwrap_or("").to_string()).or_default().push(s(c, "version").unwrap_or("").to_string());        }        m.values_mut().for_each(|v| v.sort());        m    };    let (va, vb) = (vers(&a), vers(&b));    let added: Vec<Value> = vb.iter().filter(|(k, _)| !va.contains_key(*k)).map(|(k, v)| json!({ "name": k, "version": v.join(", ") })).collect();    let removed: Vec<Value> = va.iter().filter(|(k, _)| !vb.contains_key(*k)).map(|(k, v)| json!({ "name": k, "version": v.join(", ") })).collect();    let changed: Vec<Value> = va.iter().filter_map(|(k, v)| vb.get(k).filter(|w| *w != v).map(|w| json!({ "name": k, "from": v.join(", "), "to": w.join(", ") }))).collect();    Ok(json!({ "added": added, "removed": removed, "changed": changed }))}fn input(c: &Value, i: usize) -> Result<(String, Option<String>, String), String> {    let name = s(c, "name").filter(|n| !n.is_empty()).ok_or(format!("component {}: a component needs a `name`", i))?.to_string();    let version = s(c, "version").map(str::to_string);    let purl = match c.get("purl") {        Some(Value::String(p)) => { parse_purl(p).map_err(|e| format!("component {}: {}", i, e))?; p.clone() }        Some(p) if p.is_object() => make_purl(p).map_err(|e| format!("component {}: {}", i, e))?,        _ => String::new(),    };    Ok((name, version, purl))}/// Builds CycloneDX 1.5 JSON. `root` is `{ name, version }`; a component is `{ name, version, purl, licenses, group, type, depends_on }`, `purl` a string or parts, `depends_on` other components' names. `serial` is a UUID the caller keeps, so the output is repeatable.pub fn sbom_cyclonedx(root: serde_json::Value, components: Vec<serde_json::Value>, serial: &str) -> Result<String, String> {    let rname = s(&root, "name").filter(|n| !n.is_empty()).ok_or("the root needs a `name`")?;    if serial.len() != 36 || !serial.chars().enumerate().all(|(i, c)| if [8, 13, 18, 23].contains(&i) { c == '-' } else { c.is_ascii_hexdigit() }) {        return Err(format!("`{}` is not a UUID", serial));    }    let rref = format!("{}@{}", rname, s(&root, "version").unwrap_or(""));    let mut names: HashMap<String, String> = HashMap::new();    let mut list = vec![];    for (i, c) in components.iter().enumerate() {        let (name, version, purl) = input(c, i)?;        let r = if purl.is_empty() { format!("{}@{}", name, version.clone().unwrap_or_default()) } else { purl.clone() };        names.insert(name.clone(), r.clone());        let mut m = Map::new();        m.insert("type".into(), json!(s(c, "type").unwrap_or("library")));        m.insert("bom-ref".into(), json!(r));        if let Some(g) = s(c, "group") { m.insert("group".into(), json!(g)); }        m.insert("name".into(), json!(name));        if let Some(v) = version { m.insert("version".into(), json!(v)); }        if !purl.is_empty() { m.insert("purl".into(), json!(purl)); }        let lic: Vec<Value> = c.get("licenses").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(|l| {            if l.contains(' ') { json!({ "expression": l }) } else { json!({ "license": { "id": l } }) }        }).collect();        if !lic.is_empty() { m.insert("licenses".into(), Value::Array(lic)); }        list.push(Value::Object(m));    }    let mut deps = vec![];    let mut top = vec![];    for c in &components {        let n = s(c, "name").unwrap_or("");        let on = deps_of(c, &names, n)?;        deps.push(json!({ "ref": names[n], "dependsOn": on }));        if c.get("direct").and_then(Value::as_bool).unwrap_or(true) { top.push(json!(names[n])); }    }    deps.insert(0, json!({ "ref": rref, "dependsOn": top }));    let bom = json!({        "bomFormat": "CycloneDX", "specVersion": "1.5", "serialNumber": format!("urn:uuid:{}", serial.to_lowercase()), "version": 1,        "metadata": { "component": { "type": s(&root, "type").unwrap_or("application"), "bom-ref": rref, "name": rname, "version": s(&root, "version") } },        "components": list, "dependencies": deps,    });    serde_json::to_string_pretty(&bom).map_err(|e| e.to_string())}fn deps_of(c: &Value, names: &HashMap<String, String>, who: &str) -> Result<Vec<String>, String> {    let mut on = vec![];    for d in c.get("depends_on").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {        on.push(names.get(d).cloned().ok_or(format!("{} depends on {}, which is not a component", who, d))?);    }    Ok(on)}fn spdx_id(n: &str) -> String {    n.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' }).collect()}/// Builds SPDX 2.3 JSON from the same input as `sbom_cyclonedx`. `created` is a time like `2026-11-01T09:00:00Z`, `namespace` the document's unique URI.pub fn sbom_spdx(root: serde_json::Value, components: Vec<serde_json::Value>, namespace: &str, created: &str) -> Result<String, String> {    let rname = s(&root, "name").filter(|n| !n.is_empty()).ok_or("the root needs a `name`")?;    if !namespace.contains("://") {        return Err("`namespace` must be a URI".to_string());    }    let rid = format!("SPDXRef-Package-{}", spdx_id(rname));    let mut names = HashMap::new();    let mut pkgs = vec![json!({ "SPDXID": rid, "name": rname, "versionInfo": s(&root, "version"), "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "NOASSERTION", "licenseDeclared": "NOASSERTION", "copyrightText": "NOASSERTION" })];    let mut rels = vec![json!({ "spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": rid })];    for (i, c) in components.iter().enumerate() {        let (name, version, purl) = input(c, i)?;        let id = format!("SPDXRef-Package-{}-{}", spdx_id(&name), spdx_id(version.as_deref().unwrap_or("")));        names.insert(name.clone(), id.clone());        let lic = c.get("licenses").and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" AND ")).filter(|l| !l.is_empty()).unwrap_or("NOASSERTION".to_string());        let mut m = Map::new();        m.insert("SPDXID".into(), json!(id));        m.insert("name".into(), json!(name));        if let Some(v) = version { m.insert("versionInfo".into(), json!(v)); }        m.insert("downloadLocation".into(), json!("NOASSERTION"));        m.insert("filesAnalyzed".into(), json!(false));        m.insert("licenseConcluded".into(), json!(lic));        m.insert("licenseDeclared".into(), json!("NOASSERTION"));        m.insert("copyrightText".into(), json!("NOASSERTION"));        if !purl.is_empty() {            m.insert("externalRefs".into(), json!([{ "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": purl }]));        }        pkgs.push(Value::Object(m));    }    for c in &components {        let n = s(c, "name").unwrap_or("");        if c.get("direct").and_then(Value::as_bool).unwrap_or(true) {            rels.push(json!({ "spdxElementId": rid, "relationshipType": "DEPENDS_ON", "relatedSpdxElement": names[n] }));        }        for d in deps_of(c, &names, n)? {            rels.push(json!({ "spdxElementId": names[n], "relationshipType": "DEPENDS_ON", "relatedSpdxElement": d }));        }    }    let doc = json!({        "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": rname, "documentNamespace": namespace,        "creationInfo": { "created": created, "creators": ["Tool: quillo-sbom"] }, "packages": pkgs, "relationships": rels,    });    serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())}
Lock files lockfiles.qwer · 1.0.0Cargo.lock, package-lock.json in versions 1 to 3, yarn.lock classic and Berry, pnpm-lock.yaml from v5 to v9, poetry.lock and go.sum read into one list of name, version, source and hash. Packages locked at several versions, those from outside a registry, and between two files what was added, removed, upgraded, downgraded, or kept its version and changed its hash.dependenciessupply-chainnpmcargo5 views · 8 downloadsOSV vulnerabilities osv.qwer · 1.0.0Ask OSV.dev about packages by name or purl and in batches with pages followed, evaluate affected ranges with each ecosystem's own version order (semver, PEP 440, Debian, RubyGems, Maven), and turn CVSS 3 vectors into scores.securitydependenciesapisemver2 views · 7 downloadsGraphQL query checks graphql_parse.qwer · 1.0.0GraphQL queries read before they run: their operations, fields and depth, limits that refuse expensive queries, and a check against the schema.graphqlapivalidationsecurity6 views · 8 downloadsHTTP Archive files har.qwer · 1.0.0HAR files from a browser's network panel read: pages with their load times, each request flat with timings and sizes (-1 given as null, SSL not counted twice), the slowest and largest requests, totals by host and type. Secrets masked from headers, cookies, query strings, form and JSON bodies before the file goes on a ticket, with a list of what was found and never its value.httpperformancedebuggingsecurity3 views · 6 downloadsSARIF reports sarif.qwer · 1.0.0SARIF 2.1 reports from code scanners read into flat results, with rules found by index or id, messages filled in, severities defaulted as the spec says and places followed through artifacts. Reports built and merged by tool with their indexes redone, a summary by rule and file, and what is new since a base report.static-analysiscisecurityreports3 views · 8 downloadsSQL parsing sql_parse.qwer · 1.0.0SQL read as a database would: whether it parses in a dialect, its statement kind, the tables it reads and writes, and a fingerprint for grouping logs.sqldatabasesvalidationsecurityreports9 views · 12 downloads
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

On Software bills of materials: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.