#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use crate::scanner::{CryptoFinding, CryptoType, Ecosystem};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Bom {
pub bom_format: String,
pub spec_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub serial_number: Option<String>,
pub version: u32,
pub metadata: Metadata,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub components: Vec<Component>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<Dependency>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub vulnerabilities: Vec<Vulnerability>,
}
impl Bom {
pub fn new(target: &str) -> Self {
Self {
bom_format: "CycloneDX".to_string(),
spec_version: "1.6".to_string(),
serial_number: None,
version: 1,
metadata: Metadata::for_target(target),
components: Vec::new(),
dependencies: Vec::new(),
vulnerabilities: Vec::new(),
}
}
pub fn library_ref(&self, name: &str, version: Option<&str>) -> Option<String> {
self.components
.iter()
.find(|c| {
c.component_type == ComponentType::Library
&& c.name == name
&& c.version.as_deref() == version
})
.and_then(|c| c.bom_ref.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Metadata {
pub timestamp: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<Component>,
}
impl Metadata {
fn for_target(target: &str) -> Self {
Self {
timestamp: chrono::Utc::now().to_rfc3339(),
tools: vec![Tool {
name: "slow_lynx".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}],
component: Some(Component {
component_type: ComponentType::Application,
bom_ref: None,
name: target.to_string(),
version: None,
purl: None,
evidence: None,
crypto_properties: None,
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Component {
#[serde(rename = "type")]
pub component_type: ComponentType,
#[serde(rename = "bom-ref", skip_serializing_if = "Option::is_none")]
pub bom_ref: Option<String>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<Evidence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub crypto_properties: Option<CryptoProperties>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentType {
Application,
Library,
CryptographicAsset,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Evidence {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub occurrences: Vec<Occurrence>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Occurrence {
pub location: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CryptoProperties {
pub asset_type: AssetType,
#[serde(skip_serializing_if = "Option::is_none")]
pub algorithm_properties: Option<AlgorithmProperties>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AssetType {
Algorithm,
Certificate,
Protocol,
RelatedCryptoMaterial,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlgorithmProperties {
pub primitive: Primitive,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameter_set_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub curve: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub classical_security_level: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nist_quantum_security_level: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Primitive {
Kem,
Signature,
Pke,
KeyAgree,
BlockCipher,
StreamCipher,
Hash,
Mac,
Kdf,
Drbg,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dependency {
#[serde(rename = "ref")]
pub bom_ref: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vulnerability {
pub id: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ratings: Vec<Rating>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub affects: Vec<Affects>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rating {
#[serde(skip_serializing_if = "Option::is_none")]
pub score: Option<f32>,
pub severity: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Affects {
#[serde(rename = "ref")]
pub bom_ref: String,
}
pub fn findings_to_cbom(findings: &[CryptoFinding], target: &str) -> Bom {
let mut bom = Bom::new(target);
let mut lib_index: HashMap<String, usize> = HashMap::new();
let mut algo_emitted: HashSet<String> = HashSet::new();
let mut dep_index: HashMap<String, usize> = HashMap::new();
let mut deps: Vec<Dependency> = Vec::new();
for finding in findings {
let version = finding.version.clone();
let lib_key = format!(
"{:?}|{}|{}",
finding.ecosystem,
finding.name,
version.as_deref().unwrap_or("")
);
let lib_idx = *lib_index.entry(lib_key).or_insert_with(|| {
let purl = build_purl(&finding.ecosystem, &finding.name, version.as_deref());
let bom_ref = purl
.clone()
.unwrap_or_else(|| format!("lib/{}", finding.name));
bom.components.push(Component {
component_type: ComponentType::Library,
bom_ref: Some(bom_ref),
name: finding.name.clone(),
version: version.clone(),
purl,
evidence: Some(Evidence {
occurrences: Vec::new(),
}),
crypto_properties: None,
});
bom.components.len() - 1
});
if let Some(evidence) = bom.components[lib_idx].evidence.as_mut() {
if !evidence
.occurrences
.iter()
.any(|o| o.location == finding.path)
{
evidence.occurrences.push(Occurrence {
location: finding.path.clone(),
});
}
}
let lib_ref = bom.components[lib_idx]
.bom_ref
.clone()
.expect("library components always carry a bom-ref");
for spec in algorithms_for_library(&finding.name) {
let algo_ref = format!("crypto/algorithm/{}", slug(spec.name));
if algo_emitted.insert(algo_ref.clone()) {
bom.components.push(Component {
component_type: ComponentType::CryptographicAsset,
bom_ref: Some(algo_ref.clone()),
name: spec.name.to_string(),
version: None,
purl: None,
evidence: None,
crypto_properties: Some(CryptoProperties {
asset_type: AssetType::Algorithm,
algorithm_properties: Some(AlgorithmProperties {
primitive: spec.primitive.clone(),
parameter_set_identifier: spec.parameter_set.map(str::to_string),
curve: spec.curve.map(str::to_string),
classical_security_level: spec.classical_security_level,
nist_quantum_security_level: spec.nist_quantum_security_level,
}),
oid: spec.oid.map(str::to_string),
}),
});
}
add_dependency(&mut deps, &mut dep_index, &lib_ref, algo_ref);
}
if finding.crypto_type == CryptoType::SslTls {
let proto_ref = "crypto/protocol/tls".to_string();
if algo_emitted.insert(proto_ref.clone()) {
bom.components.push(Component {
component_type: ComponentType::CryptographicAsset,
bom_ref: Some(proto_ref.clone()),
name: "TLS".to_string(),
version: None,
purl: None,
evidence: None,
crypto_properties: Some(CryptoProperties {
asset_type: AssetType::Protocol,
algorithm_properties: None,
oid: None,
}),
});
}
add_dependency(&mut deps, &mut dep_index, &lib_ref, proto_ref);
}
}
bom.dependencies = deps;
bom
}
fn add_dependency(
deps: &mut Vec<Dependency>,
dep_index: &mut HashMap<String, usize>,
lib_ref: &str,
target_ref: String,
) {
let idx = *dep_index.entry(lib_ref.to_string()).or_insert_with(|| {
deps.push(Dependency {
bom_ref: lib_ref.to_string(),
depends_on: Vec::new(),
});
deps.len() - 1
});
if !deps[idx].depends_on.contains(&target_ref) {
deps[idx].depends_on.push(target_ref);
}
}
fn build_purl(ecosystem: &Ecosystem, name: &str, version: Option<&str>) -> Option<String> {
let kind = match ecosystem {
Ecosystem::Cargo => "cargo",
Ecosystem::Npm => "npm",
Ecosystem::PyPI => "pypi",
Ecosystem::System => return None,
};
let encoded_name = if *ecosystem == Ecosystem::Npm {
if let Some(rest) = name.strip_prefix('@') {
format!("%40{}", rest)
} else {
name.to_string()
}
} else {
name.to_string()
};
let version_suffix = version.map(|v| format!("@{}", v)).unwrap_or_default();
Some(format!("pkg:{}/{}{}", kind, encoded_name, version_suffix))
}
pub fn format_uuid_v4(mut bytes: [u8; 16]) -> String {
bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
}
#[derive(Debug)]
struct AlgorithmSpec {
name: &'static str,
primitive: Primitive,
parameter_set: Option<&'static str>,
curve: Option<&'static str>,
classical_security_level: Option<u32>,
nist_quantum_security_level: Option<u32>,
oid: Option<&'static str>,
}
#[allow(clippy::too_many_arguments)]
fn algo(
name: &'static str,
primitive: Primitive,
parameter_set: Option<&'static str>,
curve: Option<&'static str>,
classical_security_level: Option<u32>,
nist_quantum_security_level: Option<u32>,
oid: Option<&'static str>,
) -> AlgorithmSpec {
AlgorithmSpec {
name,
primitive,
parameter_set,
curve,
classical_security_level,
nist_quantum_security_level,
oid,
}
}
fn rsa() -> AlgorithmSpec {
algo(
"RSA-2048",
Primitive::Pke,
Some("2048"),
None,
Some(112),
Some(0),
Some("1.2.840.113549.1.1.1"),
)
}
fn ecdsa() -> AlgorithmSpec {
algo(
"ECDSA-P256",
Primitive::Signature,
Some("P-256"),
Some("P-256"),
Some(128),
Some(0),
Some("1.2.840.10045.2.1"),
)
}
fn dh() -> AlgorithmSpec {
algo(
"DH-2048",
Primitive::KeyAgree,
Some("2048"),
None,
Some(112),
Some(0),
None,
)
}
fn ecdh() -> AlgorithmSpec {
algo(
"ECDH-P256",
Primitive::KeyAgree,
Some("P-256"),
Some("P-256"),
Some(128),
Some(0),
None,
)
}
fn ed25519() -> AlgorithmSpec {
algo(
"Ed25519",
Primitive::Signature,
None,
Some("Curve25519"),
Some(128),
Some(0),
Some("1.3.101.112"),
)
}
fn x25519() -> AlgorithmSpec {
algo(
"X25519",
Primitive::KeyAgree,
None,
Some("Curve25519"),
Some(128),
Some(0),
Some("1.3.101.110"),
)
}
fn aes256() -> AlgorithmSpec {
algo(
"AES-256",
Primitive::BlockCipher,
Some("256"),
None,
Some(256),
Some(5),
Some("2.16.840.1.101.3.4.1"),
)
}
fn chacha20() -> AlgorithmSpec {
algo(
"ChaCha20",
Primitive::StreamCipher,
Some("256"),
None,
Some(256),
Some(5),
None,
)
}
fn poly1305() -> AlgorithmSpec {
algo(
"Poly1305",
Primitive::Mac,
None,
None,
Some(128),
Some(5),
None,
)
}
fn sha256() -> AlgorithmSpec {
algo(
"SHA-256",
Primitive::Hash,
Some("256"),
None,
Some(128),
Some(2),
Some("2.16.840.1.101.3.4.2.1"),
)
}
fn hmac() -> AlgorithmSpec {
algo("HMAC", Primitive::Mac, None, None, None, None, None)
}
fn mlkem768() -> AlgorithmSpec {
algo(
"ML-KEM-768",
Primitive::Kem,
Some("ML-KEM-768"),
None,
None,
Some(3),
None,
)
}
fn mldsa65() -> AlgorithmSpec {
algo(
"ML-DSA-65",
Primitive::Signature,
Some("ML-DSA-65"),
None,
None,
Some(3),
None,
)
}
fn algorithms_for_library(name: &str) -> Vec<AlgorithmSpec> {
let n = name.to_lowercase();
match n.as_str() {
"jose" | "node-jose" | "jsonwebtoken" | "jws" | "jwa" | "jsrsasign" => {
return vec![rsa(), ecdsa(), hmac(), sha256()];
}
"elliptic" | "@noble/curves" => return vec![ecdsa(), ecdh()],
"secp256k1" | "@noble/secp256k1" | "eccrypto" => return vec![ecdsa()],
"node-rsa" | "public-encrypt" => return vec![rsa()],
"browserify-sign" => return vec![rsa(), ecdsa()],
"diffie-hellman" => return vec![dh()],
"@noble/ciphers" => return vec![aes256(), chacha20()],
"crypto" => return vec![rsa(), ecdsa(), ecdh(), aes256(), sha256(), hmac()],
"crypto-browserify" => return vec![rsa(), aes256(), sha256(), hmac()],
"openpgp" => return vec![rsa(), ecdsa(), aes256(), sha256()],
"selfsigned" | "pem" => return vec![rsa(), sha256()],
"pbkdf2" => {
return vec![algo("PBKDF2", Primitive::Kdf, None, None, None, None, None)];
}
"scrypt-js" => {
return vec![algo("scrypt", Primitive::Kdf, None, None, None, None, None)];
}
"@noble/hashes" | "hash.js" | "sha.js" | "create-hash" => return vec![sha256()],
"create-hmac" => return vec![hmac()],
"keccak" => return vec![sha256_variant("SHA3-256")],
"blakejs" => {
return vec![algo(
"BLAKE2",
Primitive::Hash,
None,
None,
Some(128),
Some(2),
None,
)];
}
"md5" => {
return vec![algo(
"MD5",
Primitive::Hash,
Some("128"),
None,
Some(0),
None,
Some("1.2.840.113549.2.5"),
)];
}
"@noble/post-quantum" => return vec![mlkem768(), mldsa65()],
"randombytes" | "buffer-equal-constant-time" => return Vec::new(),
_ => {}
}
if n.contains("oqs") || n.contains("pqcrypto") {
return vec![mlkem768(), mldsa65()];
}
if n.contains("kyber") {
return vec![mlkem768()];
}
if n.contains("dilithium") {
return vec![mldsa65()];
}
if n.contains("falcon") {
return vec![algo(
"Falcon-512",
Primitive::Signature,
Some("Falcon-512"),
None,
None,
Some(1),
None,
)];
}
if n.contains("sphincs") {
return vec![algo(
"SLH-DSA (SPHINCS+)",
Primitive::Signature,
Some("SPHINCS+-128s"),
None,
None,
Some(1),
None,
)];
}
if n.contains("openssl")
|| n.contains("libssl")
|| n.contains("libcrypto")
|| n.contains("boringssl")
|| n.contains("libressl")
|| n.contains("mbedtls")
|| n.contains("wolfssl")
{
return vec![rsa(), ecdsa(), ecdh(), aes256(), sha256()];
}
if n.contains("rustls") {
return vec![x25519(), ecdsa(), aes256(), chacha20(), sha256()];
}
if n.contains("sodium") || n == "nacl" || n.contains("tweetnacl") {
return vec![x25519(), ed25519(), chacha20(), poly1305()];
}
if n == "ring" {
return vec![ed25519(), x25519(), aes256(), chacha20(), sha256(), hmac()];
}
if n.contains("bouncycastle")
|| n.contains("cryptopp")
|| n.contains("libgcrypt")
|| n.contains("node-forge")
|| n.contains("crypto-js")
|| n.contains("cryptography")
|| n.contains("pycryptodome")
{
return vec![rsa(), aes256(), sha256()];
}
if n.contains("ed25519") {
return vec![ed25519()];
}
if n.contains("x25519") {
return vec![x25519()];
}
if n.contains("ecdsa") || n.contains("ecc") {
return vec![ecdsa()];
}
if n.contains("ecdh") {
return vec![ecdh()];
}
if n == "rsa" {
return vec![rsa()];
}
if n.contains("dsa") {
return vec![algo(
"DSA",
Primitive::Signature,
None,
None,
Some(112),
Some(0),
Some("1.2.840.10040.4.1"),
)];
}
if n.contains("chacha20") {
return vec![chacha20()];
}
if n.contains("aes") {
return vec![aes256()];
}
if n.contains("sha3") {
return vec![sha256_variant("SHA3-256")];
}
if n.contains("sha2") {
return vec![sha256()];
}
if n.contains("blake2") {
return vec![algo(
"BLAKE2",
Primitive::Hash,
None,
None,
Some(128),
Some(2),
None,
)];
}
if n.contains("blake3") {
return vec![algo(
"BLAKE3",
Primitive::Hash,
None,
None,
Some(128),
Some(2),
None,
)];
}
if n.contains("argon2") {
return vec![algo("Argon2", Primitive::Kdf, None, None, None, None, None)];
}
if n.contains("bcrypt") {
return vec![algo("bcrypt", Primitive::Kdf, None, None, None, None, None)];
}
if n.contains("hashlib") {
return vec![sha256()];
}
Vec::new()
}
fn sha256_variant(name: &'static str) -> AlgorithmSpec {
algo(
name,
Primitive::Hash,
Some("256"),
None,
Some(128),
Some(2),
None,
)
}
fn slug(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_hyphen = false;
for c in s.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_hyphen = false;
} else if !last_hyphen {
out.push('-');
last_hyphen = true;
}
}
out.trim_matches('-').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{CryptoType, FindingType, VersionSource};
use std::collections::HashSet;
fn finding(
name: &str,
eco: Ecosystem,
ctype: CryptoType,
ver: Option<&str>,
path: &str,
) -> CryptoFinding {
CryptoFinding {
path: path.to_string(),
name: name.to_string(),
finding_type: FindingType::DependencyManifest,
crypto_type: ctype,
ecosystem: eco,
version: ver.map(str::to_string),
version_source: VersionSource::Declared,
details: String::new(),
}
}
#[test]
fn envelope_is_cyclonedx_1_6() {
let bom = findings_to_cbom(&[], "target");
assert_eq!(bom.bom_format, "CycloneDX");
assert_eq!(bom.spec_version, "1.6");
assert_eq!(bom.version, 1);
}
#[test]
fn every_dependency_ref_resolves_to_a_component() {
let findings = vec![
finding(
"ring",
Ecosystem::Cargo,
CryptoType::GeneralCrypto,
Some("0.17"),
"/p/Cargo.toml",
),
finding(
"openssl",
Ecosystem::Cargo,
CryptoType::SslTls,
Some("0.10"),
"/p/Cargo.toml",
),
];
let bom = findings_to_cbom(&findings, "target");
let refs: HashSet<&str> = bom
.components
.iter()
.filter_map(|c| c.bom_ref.as_deref())
.collect();
for dep in &bom.dependencies {
assert!(
refs.contains(dep.bom_ref.as_str()),
"dangling ref {}",
dep.bom_ref
);
for target in &dep.depends_on {
assert!(
refs.contains(target.as_str()),
"dangling dependsOn {}",
target
);
}
}
}
#[test]
fn shared_algorithms_are_deduplicated() {
let findings = vec![
finding(
"ring",
Ecosystem::Cargo,
CryptoType::GeneralCrypto,
None,
"/a",
),
finding("openssl", Ecosystem::Cargo, CryptoType::SslTls, None, "/b"),
];
let bom = findings_to_cbom(&findings, "t");
let sha = bom
.components
.iter()
.filter(|c| c.name == "SHA-256")
.count();
assert_eq!(sha, 1, "SHA-256 should be a single shared component");
}
#[test]
fn unknown_library_infers_no_algorithms() {
let findings = vec![finding(
"totally-unknown-lib",
Ecosystem::Cargo,
CryptoType::Unknown,
None,
"/a",
)];
let bom = findings_to_cbom(&findings, "t");
let assets = bom
.components
.iter()
.filter(|c| c.component_type == ComponentType::CryptographicAsset)
.count();
assert_eq!(assets, 0);
}
#[test]
fn tls_library_emits_protocol_asset() {
let findings = vec![finding(
"openssl",
Ecosystem::System,
CryptoType::SslTls,
None,
"/lib/libssl.so",
)];
let bom = findings_to_cbom(&findings, "t");
let has_tls = bom.components.iter().any(|c| {
c.component_type == ComponentType::CryptographicAsset
&& c.name == "TLS"
&& matches!(
c.crypto_properties.as_ref().map(|p| &p.asset_type),
Some(AssetType::Protocol)
)
});
assert!(
has_tls,
"SSL/TLS library should expose a TLS protocol asset"
);
}
#[test]
fn purls_only_for_packaged_ecosystems() {
let findings = vec![
finding(
"ring",
Ecosystem::Cargo,
CryptoType::GeneralCrypto,
Some("0.17"),
"/a",
),
finding(
"libssl.so",
Ecosystem::System,
CryptoType::SslTls,
None,
"/lib/libssl.so",
),
];
let bom = findings_to_cbom(&findings, "t");
let ring = bom.components.iter().find(|c| c.name == "ring").unwrap();
assert_eq!(ring.purl.as_deref(), Some("pkg:cargo/ring@0.17"));
let sys = bom
.components
.iter()
.find(|c| c.name == "libssl.so")
.unwrap();
assert_eq!(sys.purl, None);
}
#[test]
fn scoped_npm_name_encodes_at_sign_in_purl() {
let findings = vec![
finding(
"@scope/cryptolib",
Ecosystem::Npm,
CryptoType::GeneralCrypto,
Some("2.4.0"),
"/p/package.json",
),
finding(
"node-forge",
Ecosystem::Npm,
CryptoType::GeneralCrypto,
Some("1.3.1"),
"/p/package.json",
),
];
let bom = findings_to_cbom(&findings, "t");
let scoped = bom
.components
.iter()
.find(|c| c.name == "@scope/cryptolib")
.unwrap();
assert_eq!(
scoped.purl.as_deref(),
Some("pkg:npm/%40scope/cryptolib@2.4.0")
);
let plain = bom
.components
.iter()
.find(|c| c.name == "node-forge")
.unwrap();
assert_eq!(plain.purl.as_deref(), Some("pkg:npm/node-forge@1.3.1"));
}
#[test]
fn vulnerabilities_omitted_when_empty_and_present_when_populated() {
let findings = vec![finding(
"ring",
Ecosystem::Cargo,
CryptoType::GeneralCrypto,
Some("0.16.0"),
"/p/Cargo.toml",
)];
let mut bom = findings_to_cbom(&findings, "t");
assert!(bom.vulnerabilities.is_empty());
let json = serde_json::to_value(&bom).unwrap();
assert!(
json.get("vulnerabilities").is_none(),
"key omitted when empty"
);
let lib_ref = bom
.library_ref("ring", Some("0.16.0"))
.expect("ring library component exists");
bom.vulnerabilities.push(Vulnerability {
id: "RUSTSEC-TEST-0001".to_string(),
ratings: vec![Rating {
score: Some(9.8),
severity: "critical".to_string(),
method: Some("CVSSv3".to_string()),
}],
description: Some("test".to_string()),
affects: vec![Affects {
bom_ref: lib_ref.clone(),
}],
});
let json = serde_json::to_value(&bom).unwrap();
let vulns = json["vulnerabilities"].as_array().expect("array present");
assert_eq!(vulns.len(), 1);
assert_eq!(vulns[0]["id"], "RUSTSEC-TEST-0001");
assert_eq!(vulns[0]["affects"][0]["ref"], serde_json::json!(lib_ref));
assert_eq!(vulns[0]["ratings"][0]["severity"], "critical");
}
#[test]
fn uuid_v4_has_correct_shape_and_bits() {
let u = format_uuid_v4([0u8; 16]);
assert_eq!(u.len(), 36);
let b = u.as_bytes();
assert_eq!(b[8], b'-');
assert_eq!(b[13], b'-');
assert_eq!(b[18], b'-');
assert_eq!(b[23], b'-');
assert_eq!(b[14], b'4', "version nibble must be 4");
assert!(
matches!(b[19], b'8' | b'9' | b'a' | b'b'),
"variant bits must be 10xx"
);
}
#[test]
fn serialized_keys_are_cyclonedx_shaped() {
let findings = vec![finding(
"openssl",
Ecosystem::Cargo,
CryptoType::SslTls,
Some("0.10"),
"/p/Cargo.toml",
)];
let bom = findings_to_cbom(&findings, "t");
let json = serde_json::to_value(&bom).unwrap();
assert_eq!(json["bomFormat"], "CycloneDX");
assert_eq!(json["specVersion"], "1.6");
let asset = json["components"]
.as_array()
.unwrap()
.iter()
.find(|c| c["type"] == "cryptographic-asset")
.expect("expected a cryptographic-asset component");
assert_eq!(asset["cryptoProperties"]["assetType"], "algorithm");
assert!(asset["bom-ref"].is_string());
}
#[test]
fn every_recognised_npm_package_infers_algorithms_or_is_exempt() {
const EXEMPT: [&str; 2] = ["randombytes", "buffer-equal-constant-time"];
for (name, _) in crate::scanner::npm_crypto_packages_for_test() {
let algos = algorithms_for_library(name);
if EXEMPT.contains(&name) {
assert!(algos.is_empty(), "{name} is exempt but inferred {algos:?}");
} else {
assert!(
!algos.is_empty(),
"{name} is recognised as crypto but infers no algorithms, so it \
would never appear in the quantum-risk assessment"
);
}
}
}
#[test]
fn asymmetric_npm_packages_are_flagged_quantum_vulnerable() {
for name in [
"jose",
"jsonwebtoken",
"jws",
"elliptic",
"secp256k1",
"node-rsa",
"diffie-hellman",
"public-encrypt",
"browserify-sign",
"selfsigned",
"@noble/curves",
] {
let algos = algorithms_for_library(name);
assert!(
algos
.iter()
.any(|a| a.nist_quantum_security_level == Some(0)),
"{name} should carry a Shor-breakable algorithm, got {algos:?}"
);
}
}
#[test]
fn md5_is_classically_broken_but_not_counted_as_shor_breakable() {
let algos = algorithms_for_library("md5");
assert_eq!(algos.len(), 1);
assert_eq!(algos[0].name, "MD5");
assert_eq!(algos[0].classical_security_level, Some(0));
assert_eq!(algos[0].nist_quantum_security_level, None);
}
#[test]
fn generic_npm_names_do_not_match_as_substrings() {
assert!(algorithms_for_library("morose-utils").is_empty());
assert!(algorithms_for_library("temperature").is_empty());
assert!(algorithms_for_library("command5").is_empty());
}
}