Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
01c7f21
feat(common): parse CPE 2.3 formatted strings
rlang-ta Jul 13, 2026
c6c4c0a
feat(ingestor): ingest cpe23Type external references from SPDX
rlang-ta Jul 13, 2026
9ba7c40
feat(entity): add cpe_status table for CPE-keyed vulnerability statuses
rlang-ta Jul 13, 2026
0c0a478
feat(ingestor): store CPE applicability from CVE records as cpe_status
rlang-ta Jul 13, 2026
4cdfdba
feat(ingestor): ingest affected entries from CVE ADP containers
rlang-ta Jul 13, 2026
7876f7c
feat(fundamental): match SBOM package CPEs against cpe_status in SBOM…
rlang-ta Jul 13, 2026
9237904
feat(fundamental): include CPE matches in batch severity counts
rlang-ta Jul 13, 2026
c4b1462
fix(ingestor): skip unknown-status entries in CVE CPE ingestion
rlang-ta Jul 13, 2026
2848d8f
feat(ingestor): add NVD advisory loader and NVD document format
rlang-ta Jul 15, 2026
b8483a7
feat(importer): add NVD importer with per-year feed ingestion
rlang-ta Jul 15, 2026
c8d95de
feat(fundamental): backlink SBOMs matched via package CPE in vulnerab…
rlang-ta Jul 15, 2026
05e2b8e
Merge branch 'main' into cpe-matching
rlang-ta Jul 15, 2026
137843b
feat(entity): persist CPE 2.3 extended attributes
rlang-ta Jul 15, 2026
75b8ea3
test(fundamental): cover NVD unbounded ranges and exact CPE boundaries
rlang-ta Jul 15, 2026
6fa4649
Merge remote-tracking branch 'upstream/main' into cpe-matching
rlang-ta Jul 15, 2026
072f836
docs(importer): clarify NVD year selection precedence
rlang-ta Jul 17, 2026
0a64a64
fix(ingestor): stop silently dropping unparseable CPEs
rlang-ta Jul 17, 2026
72bd735
perf(migration): add composite index on cpe(part, vendor, product)
rlang-ta Jul 17, 2026
92df70b
feat(server): add NVD importer sample data entry
rlang-ta Jul 17, 2026
1d25648
test(importer): cover the NVD runner with a mocked feed
rlang-ta Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ sea-query = "0.32.0" # keep aligned with sea-orm
semver = "1"
serde = "1.0.183"
serde-cyclonedx = "0.10.0"
serde_json = "1.0.114"
serde_json = { version = "1.0.114", features = ["raw_value"] }
serde_qs = { version = "1", features = ["actix4"] }
serde_yml = { package = "serde_yaml_ng", version = "0.10" }
sha2 = "0.11.0"
Expand Down
342 changes: 336 additions & 6 deletions common/src/cpe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,85 @@ impl Cpe {
pub fn language(&self) -> Language {
self.uri.language().clone().into()
}

pub fn sw_edition(&self) -> Component {
self.uri.sw_edition().into()
}

pub fn target_sw(&self) -> Component {
self.uri.target_sw().into()
}

pub fn target_hw(&self) -> Component {
self.uri.target_hw().into()
}

pub fn other(&self) -> Component {
self.uri.other().into()
}

/// Return a copy of this CPE with the version component normalized to ANY.
///
/// Useful for deriving a vendor/product identity key when the concrete
/// version is carried separately (e.g. via a `version_range`).
///
/// Known limitation: the extended attributes packed into the URI edition
/// component (`sw_edition`/`target_sw`/`target_hw`/`other`) are not
/// exposed by this wrapper's accessors and are therefore dropped by this
/// normalization. In practice CVE `affected[].cpes` entries are plain
/// `part:vendor:product:version` tuples, so this does not affect the
/// CVE-loader use case this method was added for.
pub fn with_any_version(&self) -> Self {
fn field(component: &Component) -> String {
match component {
Component::Any => String::new(),
Component::NotApplicable => "-".to_string(),
Component::Value(value) => encode_uri_component(value),
}
}

let part = match self.part().as_ref() {
"*" => String::new(),
other => other.to_string(),
};
let vendor = field(&self.vendor());
let product = field(&self.product());
let update = field(&self.update());
let edition = field(&self.edition());
let language = match self.language() {
Language::Any => String::new(),
Language::Language(value) => value,
};

let mut components = vec![
part,
vendor,
product,
String::new(),
update,
edition,
language,
];
// trailing empty (ANY) components must be dropped: the URI parser
// treats empty components in the middle as ANY, but rejects a
// trailing empty language component.
while components.last().is_some_and(|c| c.is_empty()) {
components.pop();
}

// Reconstructed from an already-valid CPE with only the version blanked,
// so re-parsing cannot fail; keep the original on the unreachable error
// path rather than propagate an impossible failure through the signature.
match OwnedUri::from_str(&format!("cpe:/{}", components.join(":"))) {
Ok(uri) => Self { uri },
Err(err) => {
log::warn!(
"failed to normalize CPE {self:?} to any-version, keeping original: {err}"
);
self.clone()
}
}
}
}

impl Debug for Cpe {
Expand All @@ -332,22 +411,144 @@ impl From<OwnedUri> for Cpe {
}
}

/// Split a CPE 2.3 formatted string body into its components, honoring `\`-escapes.
fn split_cpe23(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut cur = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'\\' => {
cur.push(c);
if let Some(next) = chars.next() {
cur.push(next);
}
}
':' => parts.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
parts.push(cur);
parts
}

/// Remove `\`-escapes from a CPE 2.3 formatted string component.
fn unescape_cpe23(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
out.push(next);
}
} else {
out.push(c);
}
}
out
}

/// Percent-encode a decoded component value using CPE 2.2 URI syntax,
/// mapping the `?`/`*` wildcards to their `%01`/`%02` special encodings.
fn encode_uri_component(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'?' => out.push_str("%01"),
'*' => out.push_str("%02"),
c if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') => out.push(c),
c => {
let mut buf = [0u8; 4];
for b in c.encode_utf8(&mut buf).as_bytes() {
out.push_str(&format!("%{b:02x}"));
}
}
}
}
out
}

/// Turn a raw (still escaped) CPE 2.3 component into its CPE 2.2 URI form.
fn cpe23_component_to_uri(raw: &str) -> String {
match raw {
// ANY is the empty component in URI syntax
"*" => String::new(),
"-" => "-".to_string(),
_ => encode_uri_component(&unescape_cpe23(raw)),
}
}

/// Convert a CPE 2.3 formatted string (`cpe:2.3:part:vendor:...`, 11 attribute
/// components) into a CPE 2.2 URI, packing the extended attributes
/// (sw_edition, target_sw, target_hw, other) into the edition component.
fn cpe23_to_uri(value: &str, body: &str) -> Result<OwnedUri, cpe::error::CpeError> {
let invalid = || cpe::error::CpeError::InvalidUri {
value: value.to_owned(),
};

let raw = split_cpe23(body);
if raw.len() != 11 {
return Err(invalid());
}

let part = match raw[0].as_str() {
"*" | "-" => "",
part => part,
};

let [vendor, product, version, update, edition] =
[&raw[1], &raw[2], &raw[3], &raw[4], &raw[5]].map(|c| cpe23_component_to_uri(c));
let [sw_edition, target_sw, target_hw, other] =
[&raw[7], &raw[8], &raw[9], &raw[10]].map(|c| cpe23_component_to_uri(c));

let edition = if [&sw_edition, &target_sw, &target_hw, &other]
.iter()
.all(|c| c.is_empty())
{
edition
} else {
format!("~{edition}~{sw_edition}~{target_sw}~{target_hw}~{other}")
};

let language = match raw[6].as_str() {
"*" | "-" => String::new(),
lang => unescape_cpe23(lang),
};

let mut components = vec![
part.to_string(),
vendor,
product,
version,
update,
edition,
language,
];
// the URI parser treats empty components in the middle as ANY, but fails on
// an empty trailing language component
while components.last().is_some_and(|c| c.is_empty()) {
components.pop();
}

OwnedUri::from_str(&format!("cpe:/{}", components.join(":")))
}

impl FromStr for Cpe {
type Err = <OwnedUri as FromStr>::Err;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self {
uri: OwnedUri::from_str(s)?,
})
let uri = match s.strip_prefix("cpe:2.3:") {
Some(body) => cpe23_to_uri(s, body)?,
None => OwnedUri::from_str(s)?,
};
Ok(Self { uri })
}
}

impl TryFrom<&str> for Cpe {
type Error = <OwnedUri as FromStr>::Err;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Ok(Self {
uri: OwnedUri::from_str(value)?,
})
Self::from_str(value)
}
}

Expand Down Expand Up @@ -572,4 +773,133 @@ mod test {
"61bca16a-febc-5d79-8b4d-f51fa37c876d"
);
}

#[test]
fn cpe23_simple() {
let cpe =
Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*").expect("must parse");
assert!(matches!(cpe.part(), CpeType::Application));
assert_eq!(cpe.vendor().as_ref(), "openssl");
assert_eq!(cpe.product().as_ref(), "openssl");
assert_eq!(cpe.version().as_ref(), "0.9.8w");
assert!(matches!(cpe.update(), Component::Any));
assert!(matches!(cpe.edition(), Component::Any));
assert!(matches!(cpe.language(), Language::Any));
}

#[test]
fn cpe23_same_uuid_as_cpe22() {
let cpe23 = Cpe::from_str("cpe:2.3:a:redhat:enterprise_linux:9:*:crb:*:*:*:*:*")
.expect("must parse");
assert_eq!(
cpe23.uuid().to_string(),
"61bca16a-febc-5d79-8b4d-f51fa37c876d"
);
}

#[test]
fn cpe23_not_applicable() {
let cpe = Cpe::from_str("cpe:2.3:a:busybox:busybox:-:*:*:*:*:*:*:*").expect("must parse");
assert!(matches!(cpe.version(), Component::NotApplicable));
}

#[test]
fn cpe23_escaped_characters() {
let cpe = Cpe::from_str(r"cpe:2.3:a:foo\:bar:some\+product:1.0:*:*:*:*:*:*:*")
.expect("must parse");
assert_eq!(cpe.vendor().as_ref(), "foo:bar");
assert_eq!(cpe.product().as_ref(), "some+product");
assert_eq!(cpe.version().as_ref(), "1.0");
}

#[test]
fn cpe23_embedded_wildcard() {
let cpe =
Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8*:*:*:*:*:*:*:*").expect("must parse");
assert_eq!(cpe.version().as_ref(), "0.9.8*");
}

#[test]
fn cpe23_extended_attributes() {
let cpe = Cpe::from_str("cpe:2.3:o:microsoft:windows_10:1607:*:*:*:*:*:x64:*")
.expect("must parse");
assert!(matches!(cpe.part(), CpeType::OperatingSystem));
assert_eq!(cpe.vendor().as_ref(), "microsoft");
assert_eq!(cpe.product().as_ref(), "windows_10");
assert_eq!(cpe.version().as_ref(), "1607");
// extended attributes end up in the packed edition component of the URI
assert_eq!(
cpe.to_string(),
"cpe:/o:microsoft:windows_10:1607:*~*~*~*~x64~*:*"
);
}

#[test]
fn cpe23_language() {
let cpe =
Cpe::from_str("cpe:2.3:a:vendor:product:1.0:*:*:en-us:*:*:*:*").expect("must parse");
assert_eq!(cpe.language().as_ref(), "en-US");
}

#[test]
fn cpe23_wrong_component_count() {
assert!(Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w").is_err());
assert!(Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*:*").is_err());
}

#[test]
fn cpe23_garbage_rejected() {
// unescapable garbage (spaces are not valid in any CPE component)
assert!(
Cpe::from_str("cpe:2.3:a:libfoo.a(bar.o): in function `baz':1.0:-:*:*:*:*:*:*:*")
.is_err()
);
}

#[test]
fn with_any_version_normalizes_concrete_version() {
let cpe =
Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*").expect("must parse");
let normalized = cpe.with_any_version();

assert!(matches!(normalized.part(), CpeType::Application));
assert_eq!(normalized.vendor().as_ref(), "openssl");
assert_eq!(normalized.product().as_ref(), "openssl");
assert!(matches!(normalized.version(), Component::Any));

// vendor/product identity is unchanged, only the version differs
assert_ne!(cpe.uuid(), normalized.uuid());
}

#[test]
fn with_any_version_is_idempotent_for_already_any_version() {
let cpe = Cpe::from_str("cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*").expect("must parse");
let normalized = cpe.with_any_version();
assert_eq!(cpe.uuid(), normalized.uuid());
}

#[test]
fn with_any_version_preserves_special_characters() {
let cpe = Cpe::from_str(r"cpe:2.3:a:foo\:bar:some\+product:1.0:*:*:*:*:*:*:*")
.expect("must parse");
let normalized = cpe.with_any_version();
assert_eq!(normalized.vendor().as_ref(), "foo:bar");
assert_eq!(normalized.product().as_ref(), "some+product");
assert!(matches!(normalized.version(), Component::Any));
}

#[test]
fn with_any_version_drops_unexposed_extended_attributes() {
// known limitation: sw_edition/target_sw/target_hw/other are packed
// into the URI edition component and aren't reachable through this
// wrapper's accessors, so with_any_version can't round-trip them.
// CVE affected[].cpes entries never carry these, so this doesn't
// affect the CVE-loader use case.
let cpe = Cpe::from_str("cpe:2.3:o:microsoft:windows_10:1607:*:*:*:*:*:x64:*")
.expect("must parse");
let normalized = cpe.with_any_version();
assert!(matches!(normalized.version(), Component::Any));
assert_eq!(normalized.vendor().as_ref(), "microsoft");
assert_eq!(normalized.product().as_ref(), "windows_10");
}
}
Loading
Loading