use std::collections::BTreeMap;
use serde_json::{json, Value};
use droidsaw_apk::native_lib_versions::NativeLibVersion;
#[derive(Debug, Clone, Copy)]
pub struct VexCveRule {
pub cve_id: &'static str,
pub canonical_name: &'static str,
pub fixed_in: Option<&'static str>,
pub cpe_vendor_product: Option<(&'static str, &'static str)>,
pub severity: &'static str,
}
pub const VEX_CVE_RULES: &[VexCveRule] = &[
VexCveRule {
cve_id: "CVE-2024-5535",
canonical_name: "openssl",
fixed_in: Some("3.0.14"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Critical",
},
VexCveRule {
cve_id: "CVE-2023-0286",
canonical_name: "openssl",
fixed_in: Some("1.1.1t"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2023-2650",
canonical_name: "openssl",
fixed_in: Some("1.1.1u"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2022-1292",
canonical_name: "openssl",
fixed_in: Some("1.1.1o"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2022-2068",
canonical_name: "openssl",
fixed_in: Some("1.1.1p"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2022-0778",
canonical_name: "openssl",
fixed_in: Some("1.1.1n"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2021-3711",
canonical_name: "openssl",
fixed_in: Some("1.1.1l"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Critical",
},
VexCveRule {
cve_id: "CVE-2021-3712",
canonical_name: "openssl",
fixed_in: Some("1.1.1l"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2021-3449",
canonical_name: "openssl",
fixed_in: Some("1.1.1k"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2021-23840",
canonical_name: "openssl",
fixed_in: Some("1.1.1j"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2021-23841",
canonical_name: "openssl",
fixed_in: Some("1.1.1j"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2023-5678",
canonical_name: "openssl",
fixed_in: Some("1.1.1x"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2023-3446",
canonical_name: "openssl",
fixed_in: Some("1.1.1v"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2023-4807",
canonical_name: "openssl",
fixed_in: Some("1.1.1w"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2023-0464",
canonical_name: "openssl",
fixed_in: Some("1.1.1u"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2023-0466",
canonical_name: "openssl",
fixed_in: Some("1.1.1u"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2023-0215",
canonical_name: "openssl",
fixed_in: Some("1.1.1t"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2019-1543",
canonical_name: "openssl",
fixed_in: Some("1.1.1b"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2019-1549",
canonical_name: "openssl",
fixed_in: Some("1.1.1d"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2019-1551",
canonical_name: "openssl",
fixed_in: Some("1.1.1e"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2024-2511",
canonical_name: "openssl",
fixed_in: Some("3.0.14"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2024-9143",
canonical_name: "openssl",
fixed_in: Some("3.0.16"),
cpe_vendor_product: Some(("openssl", "openssl")),
severity: "Medium",
},
VexCveRule {
cve_id: "CVE-2023-38545",
canonical_name: "libcurl",
fixed_in: Some("8.4.0"),
cpe_vendor_product: Some(("haxx", "libcurl")),
severity: "Critical",
},
VexCveRule {
cve_id: "CVE-2022-32221",
canonical_name: "libcurl",
fixed_in: Some("7.86.0"),
cpe_vendor_product: Some(("haxx", "libcurl")),
severity: "Critical",
},
VexCveRule {
cve_id: "CVE-2023-27534",
canonical_name: "libcurl",
fixed_in: Some("8.0.0"),
cpe_vendor_product: Some(("haxx", "libcurl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2024-6197",
canonical_name: "libcurl",
fixed_in: Some("8.9.0"),
cpe_vendor_product: Some(("haxx", "libcurl")),
severity: "High",
},
VexCveRule {
cve_id: "CVE-2022-37434",
canonical_name: "zlib",
fixed_in: Some("1.2.13"),
cpe_vendor_product: Some(("zlib", "zlib")),
severity: "Critical",
},
VexCveRule {
cve_id: "CVE-2018-25032",
canonical_name: "zlib",
fixed_in: Some("1.2.12"),
cpe_vendor_product: Some(("zlib", "zlib")),
severity: "High",
},
];
#[derive(Debug, Clone)]
pub struct VexClaim {
pub cve_id: String,
pub bom_ref: String,
pub status: VexStatus,
pub justification: Option<&'static str>,
pub impact_statement: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VexStatus {
NotAffected,
Fixed,
UnderInvestigation,
#[allow(dead_code, reason = "reserved for v1.x evidence rules")]
Affected,
}
impl VexStatus {
fn openvex_str(self) -> &'static str {
match self {
Self::NotAffected => "not_affected",
Self::Fixed => "fixed",
Self::UnderInvestigation => "under_investigation",
Self::Affected => "affected",
}
}
fn cyclonedx_state_str(self) -> &'static str {
match self {
Self::NotAffected => "not_affected",
Self::Fixed => "resolved",
Self::UnderInvestigation => "in_triage",
Self::Affected => "exploitable",
}
}
}
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
let (a_parts, a_suffix) = parse_version(a);
let (b_parts, b_suffix) = parse_version(b);
for i in 0..4 {
let av = a_parts.get(i).copied().unwrap_or(0);
let bv = b_parts.get(i).copied().unwrap_or(0);
match av.cmp(&bv) {
std::cmp::Ordering::Equal => continue,
ord => return ord,
}
}
a_suffix.cmp(&b_suffix)
}
fn parse_version(s: &str) -> (Vec<u32>, u8) {
let mut parts = Vec::new();
let mut current: u32 = 0;
let mut saw_digit = false;
let mut suffix: u8 = 0;
for c in s.chars() {
if let Some(d) = c.to_digit(10) {
current = current.saturating_mul(10).saturating_add(d);
saw_digit = true;
} else if c == '.' && saw_digit {
parts.push(current);
current = 0;
saw_digit = false;
} else if c.is_ascii_lowercase() && saw_digit {
suffix = u8::try_from(c).unwrap_or(0);
break;
} else {
break;
}
}
if saw_digit {
parts.push(current);
}
(parts, suffix)
}
pub fn claims_for_native_lib(info: &NativeLibVersion, bom_ref: &str) -> Vec<VexClaim> {
let mut out = Vec::new();
for rule in VEX_CVE_RULES {
let is_cross_fork = matches!(
(info.canonical_name.as_str(), rule.cpe_vendor_product),
("libressl", Some(("openssl", "openssl")))
);
if is_cross_fork {
out.push(VexClaim {
cve_id: rule.cve_id.to_owned(),
bom_ref: bom_ref.to_owned(),
status: VexStatus::NotAffected,
justification: Some("component_not_present"),
impact_statement: format!(
"Component is {} fork; OpenSSL implementation that {} is registered against is not present in this binary.",
info.canonical_name, rule.cve_id,
),
});
continue;
}
if rule.canonical_name != info.canonical_name {
continue;
}
if let Some(fixed_in) = rule.fixed_in
&& compare_versions(&info.version, fixed_in) != std::cmp::Ordering::Less
{
out.push(VexClaim {
cve_id: rule.cve_id.to_owned(),
bom_ref: bom_ref.to_owned(),
status: VexStatus::Fixed,
justification: None,
impact_statement: format!(
"Extracted version {} is at or above curated fixed-in version {}.",
info.version, fixed_in,
),
});
continue;
}
out.push(VexClaim {
cve_id: rule.cve_id.to_owned(),
bom_ref: bom_ref.to_owned(),
status: VexStatus::UnderInvestigation,
justification: None,
impact_statement: format!(
"Curated CVE table lists this CVE against {} {}; droidsaw has no static evidence yet to mark it not_affected or fixed.",
info.canonical_name, info.version,
),
});
}
out
}
pub fn cyclonedx_vulnerability(claim: &VexClaim) -> Value {
let mut analysis = serde_json::Map::new();
analysis.insert("state".into(), json!(claim.status.cyclonedx_state_str()));
if let Some(j) = claim.justification {
analysis.insert("justification".into(), json!(map_justification_to_cdx(j)));
}
if !claim.impact_statement.is_empty() {
analysis.insert("detail".into(), json!(claim.impact_statement));
}
json!({
"bom-ref": format!("{}#{}", claim.cve_id, &claim.bom_ref),
"id": claim.cve_id,
"source": { "name": "NVD" },
"affects": [
{ "ref": claim.bom_ref }
],
"analysis": Value::Object(analysis),
})
}
pub fn openvex_envelope(claims: &[VexClaim], bom_serial_number: &str) -> Value {
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let statements: Vec<Value> = claims
.iter()
.map(|c| {
let mut stmt = serde_json::Map::new();
stmt.insert(
"vulnerability".into(),
json!({ "@id": format!("https://nvd.nist.gov/vuln/detail/{}", c.cve_id), "name": c.cve_id }),
);
stmt.insert(
"products".into(),
json!([{ "@id": c.bom_ref }]),
);
stmt.insert("status".into(), json!(c.status.openvex_str()));
if let Some(j) = c.justification {
stmt.insert("justification".into(), json!(j));
}
if !c.impact_statement.is_empty() {
stmt.insert("impact_statement".into(), json!(c.impact_statement));
}
Value::Object(stmt)
})
.collect();
let tool_version = env!("CARGO_PKG_VERSION");
json!({
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": format!("urn:droidsaw:openvex:{bom_serial_number}"),
"author": format!("droidsaw {tool_version}"),
"role": "Open Source Project",
"timestamp": timestamp,
"version": 1,
"statements": statements,
})
}
fn map_justification_to_cdx(openvex_justification: &str) -> &'static str {
match openvex_justification {
"component_not_present" => "code_not_present",
"vulnerable_code_not_present" => "code_not_present",
"vulnerable_code_not_in_execute_path" => "code_not_reachable",
"vulnerable_code_cannot_be_controlled_by_adversary" => "requires_environment",
"inline_mitigations_already_exist" => "protected_by_mitigating_control",
_ => "code_not_present",
}
}
pub fn gather_claims(
apk_native_libs: &BTreeMap<String, Vec<droidsaw_apk::apk::NativeLib>>,
) -> Vec<VexClaim> {
let mut out = Vec::new();
for libs in apk_native_libs.values() {
for lib in libs {
let Some(info) = lib.version_info.as_ref() else {
continue;
};
let bom_ref = format!("pkg:generic/{}@{}", info.canonical_name, info.version);
out.extend(claims_for_native_lib(info, &bom_ref));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compare_versions_handles_canonical_semver() {
assert_eq!(compare_versions("1.1.1", "1.1.0"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("1.1.0", "1.1.1"), std::cmp::Ordering::Less);
assert_eq!(compare_versions("1.1.1", "1.1.1"), std::cmp::Ordering::Equal);
}
#[test]
fn compare_versions_orders_openssl_letter_suffix() {
assert_eq!(compare_versions("1.1.1a", "1.1.1b"), std::cmp::Ordering::Less);
assert_eq!(compare_versions("1.1.1z", "1.1.1a"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("1.1.1u", "1.1.1t"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("1.1.1a", "1.1.1"), std::cmp::Ordering::Greater);
}
#[test]
fn compare_versions_handles_major_differences() {
assert_eq!(compare_versions("3.0.0", "1.1.1u"), std::cmp::Ordering::Greater);
assert_eq!(compare_versions("1.0.2k", "1.1.1"), std::cmp::Ordering::Less);
}
#[test]
fn compare_versions_tolerates_trailing_text() {
assert_eq!(
compare_versions("1.1.1a 20 Nov 2018", "1.1.1a"),
std::cmp::Ordering::Equal,
);
}
fn nlv(name: &str, version: &str) -> NativeLibVersion {
NativeLibVersion {
canonical_name: name.to_owned(),
version: version.to_owned(),
matched_string: format!("{name} {version}"),
cpe_vendor_product: Some(("openssl".to_owned(), "openssl".to_owned())),
}
}
#[test]
fn fixed_rule_fires_when_version_at_or_above_curated_fixed_in() {
let info = nlv("openssl", "1.1.1u");
let claims = claims_for_native_lib(&info, "pkg:generic/openssl@1.1.1u");
let cve = claims.iter().find(|c| c.cve_id == "CVE-2023-0286").expect("present");
assert_eq!(cve.status, VexStatus::Fixed);
let cve = claims.iter().find(|c| c.cve_id == "CVE-2023-5678").expect("present");
assert_eq!(cve.status, VexStatus::UnderInvestigation);
}
#[test]
fn under_investigation_fires_when_version_below_fixed_in() {
let info = nlv("openssl", "1.1.1a");
let claims = claims_for_native_lib(&info, "pkg:generic/openssl@1.1.1a");
let under = claims.iter().filter(|c| c.status == VexStatus::UnderInvestigation).count();
assert!(under >= 15, "expected ≥15 under_investigation claims; got {under}");
let fixed = claims.iter().filter(|c| c.status == VexStatus::Fixed).count();
assert_eq!(fixed, 0, "no curated CVE is fixed_in <= 1.1.1a");
}
#[test]
fn cross_fork_rule_fires_for_libressl() {
let info = NativeLibVersion {
canonical_name: "libressl".to_owned(),
version: "3.7.0".to_owned(),
matched_string: "LibreSSL banner".to_owned(),
cpe_vendor_product: None,
};
let claims = claims_for_native_lib(&info, "pkg:generic/libressl@3.7.0");
for claim in &claims {
assert_eq!(claim.status, VexStatus::NotAffected);
}
}
#[test]
fn non_curated_canonical_yields_no_claims() {
let info = nlv("nghttp2", "1.50.0");
let claims = claims_for_native_lib(&info, "pkg:generic/nghttp2@1.50.0");
assert!(claims.is_empty(), "non-curated canonical must yield no claims");
}
#[test]
fn cyclonedx_vulnerability_emits_expected_fields() {
let claim = VexClaim {
cve_id: "CVE-2023-0286".to_owned(),
bom_ref: "pkg:generic/openssl@1.1.1a".to_owned(),
status: VexStatus::UnderInvestigation,
justification: None,
impact_statement: "test".to_owned(),
};
let v = cyclonedx_vulnerability(&claim);
assert_eq!(v["id"], "CVE-2023-0286");
assert_eq!(v["affects"][0]["ref"], "pkg:generic/openssl@1.1.1a");
assert_eq!(v["analysis"]["state"], "in_triage");
assert_eq!(v["analysis"]["detail"], "test");
}
#[test]
fn cyclonedx_vulnerability_emits_fixed_state() {
let claim = VexClaim {
cve_id: "CVE-2023-0286".to_owned(),
bom_ref: "pkg:generic/openssl@1.1.1u".to_owned(),
status: VexStatus::Fixed,
justification: None,
impact_statement: String::new(),
};
let v = cyclonedx_vulnerability(&claim);
assert_eq!(v["analysis"]["state"], "resolved");
}
#[test]
fn cyclonedx_vulnerability_emits_not_affected_with_justification() {
let claim = VexClaim {
cve_id: "CVE-2023-0286".to_owned(),
bom_ref: "pkg:generic/boringssl@0.0.0".to_owned(),
status: VexStatus::NotAffected,
justification: Some("component_not_present"),
impact_statement: "BoringSSL fork".to_owned(),
};
let v = cyclonedx_vulnerability(&claim);
assert_eq!(v["analysis"]["state"], "not_affected");
assert_eq!(v["analysis"]["justification"], "code_not_present");
}
#[test]
fn openvex_envelope_has_required_top_level_fields() {
let claim = VexClaim {
cve_id: "CVE-2023-0286".to_owned(),
bom_ref: "pkg:generic/openssl@1.1.1u".to_owned(),
status: VexStatus::Fixed,
justification: None,
impact_statement: String::new(),
};
let env = openvex_envelope(&[claim], "urn:uuid:test");
assert_eq!(env["@context"], "https://openvex.dev/ns/v0.2.0");
assert!(env["@id"].as_str().unwrap_or("").contains("urn:droidsaw:openvex"));
assert!(env["author"].as_str().unwrap_or("").starts_with("droidsaw"));
assert_eq!(env["version"], 1);
let statements = env["statements"].as_array().expect("statements array");
assert_eq!(statements.len(), 1);
assert_eq!(statements[0]["status"], "fixed");
assert_eq!(statements[0]["vulnerability"]["name"], "CVE-2023-0286");
}
#[test]
fn openvex_envelope_includes_justification_when_not_affected() {
let claim = VexClaim {
cve_id: "CVE-2023-0286".to_owned(),
bom_ref: "pkg:generic/boringssl@0.0.0".to_owned(),
status: VexStatus::NotAffected,
justification: Some("component_not_present"),
impact_statement: "BoringSSL fork".to_owned(),
};
let env = openvex_envelope(&[claim], "urn:uuid:test");
let stmt = &env["statements"][0];
assert_eq!(stmt["status"], "not_affected");
assert_eq!(stmt["justification"], "component_not_present");
assert_eq!(stmt["impact_statement"], "BoringSSL fork");
}
#[test]
fn curated_table_has_at_least_twenty_entries() {
assert!(
VEX_CVE_RULES.len() >= 20,
"curated CVE table must have ≥20 entries; got {}",
VEX_CVE_RULES.len(),
);
}
#[test]
fn curated_table_covers_openssl_critical_severity_recent() {
let cves: Vec<&str> = VEX_CVE_RULES.iter().map(|r| r.cve_id).collect();
for required in &["CVE-2024-5535", "CVE-2023-0286", "CVE-2022-1292", "CVE-2021-3711"] {
assert!(
cves.contains(required),
"curated table must cover anchor CVE {required}; got {cves:?}",
);
}
}
}