From 5fb979dcaba8ec47b6fbc9d367cc739511d5df4f Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 18 Oct 2026 22:54:05 +1000 Subject: [PATCH 1/3] fix(osv): one malware predicate, MAL- id/alias or CWE-506, with the CWE ids stored `fourda_depengine::is_malware(id, aliases, cwe_ids)`: a malicious-package record is an OSV `MAL-` id or alias, or carries CWE-506 in database_specific.cwe_ids. GitHub's malware advisories often have no `MAL-` alias: crossenv GHSA-c2m4-w5hm-vqjw ("crossenv is malware") has aliases ["CVE-2017-16074"] and cwe_ids ["CWE-506"] (live 2026-10-18), so the parent-release predicate read it as an unfixed ordinary advisory. No summary keyword is read (the release gate's "Malicious code in" prefix check is replaced by the shared rule). Phase 128 adds osv_advisories.cwe_ids (additive; schema 128, so a 127 binary refuses the database through the existing too-new guard) and makes every ecosystem's advisory sync due, so rows stored before it are re-upserted with their CWE ids on the next cycle instead of staying NULL. The sync stores database_specific.cwe_ids; StoredAdvisory and MatchedAdvisory carry them. Wired where MAL- was matched: the parent-release Advisory (osv/parent_release.rs) and the release gate (osv/release_advisories.rs). Severity is left to #986. No new egress. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7 --- crates/fourda-depengine/src/lib.rs | 5 ++ crates/fourda-depengine/src/malware.rs | 67 +++++++++++++++++++ src-tauri/contract/app-schema.sql | 6 +- src-tauri/src/db/migrations.rs | 46 ++++++++++++- src-tauri/src/db/osv_advisories.rs | 32 +++++++-- src-tauri/src/evidence/stack_change_tests.rs | 1 + src-tauri/src/evidence/upgrade_plan_tests.rs | 1 + src-tauri/src/osv/fix_target_tests.rs | 1 + src-tauri/src/osv/identity.rs | 1 + src-tauri/src/osv/matching.rs | 1 + src-tauri/src/osv/parent_release.rs | 12 ++-- .../src/osv/parent_release_graph_vectors.rs | 17 +++++ src-tauri/src/osv/parent_release_tests.rs | 23 ++++++- src-tauri/src/osv/parent_release_vectors.rs | 7 +- src-tauri/src/osv/reachability_tests.rs | 1 + src-tauri/src/osv/release_advisories.rs | 21 +++--- src-tauri/src/osv/release_advisories_tests.rs | 19 ++++-- src-tauri/src/osv/sync.rs | 47 +++++++++++++ src-tauri/src/osv/types.rs | 30 +++++++++ src-tauri/src/preemption.rs | 3 + src-tauri/src/sources/osv_tests.rs | 1 + 21 files changed, 308 insertions(+), 34 deletions(-) create mode 100644 crates/fourda-depengine/src/malware.rs diff --git a/crates/fourda-depengine/src/lib.rs b/crates/fourda-depengine/src/lib.rs index aa6ab3cdc..c7d16e100 100644 --- a/crates/fourda-depengine/src/lib.rs +++ b/crates/fourda-depengine/src/lib.rs @@ -21,6 +21,8 @@ //! findings per installed copy with each copy's fix targets; //! - [`exposure`]: the same check for one advisory (the scorer's verdict); //! - [`lines`]: the per-project, per-release-line upgrade targets; +//! - [`malware`]: whether an advisory is a malicious-package record (`MAL-` +//! id or alias, or CWE-506); //! - [`registry`]: the registry's releases and the ones it withdrew (yanked //! or deprecated), which no target may name. //! @@ -32,11 +34,14 @@ pub mod ecosystem; pub mod exposure; pub mod fix_target; pub mod lines; +pub mod malware; pub mod matching; pub mod osv; pub mod range; pub mod registry; pub mod version_order; +pub use malware::is_malware; + #[cfg(target_arch = "wasm32")] mod wasm; diff --git a/crates/fourda-depengine/src/malware.rs b/crates/fourda-depengine/src/malware.rs new file mode 100644 index 000000000..d3d3c0949 --- /dev/null +++ b/crates/fourda-depengine/src/malware.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: FSL-1.1-Apache-2.0 +//! Whether an advisory record is a malicious-package record: one rule for +//! every reader (parent releases, the release gate, severity), in both +//! engines. +//! +//! A record is malware when its id or one of its aliases is an OSV `MAL-` id, +//! or when it carries CWE-506 ("Embedded Malicious Code") in +//! `database_specific.cwe_ids`. The CWE arm is not optional: GitHub's +//! malware advisories often have no `MAL-` alias. Verified live 2026-10-18: +//! GHSA-c2m4-w5hm-vqjw ("crossenv is malware") has aliases +//! `["CVE-2017-16074"]` and `cwe_ids: ["CWE-506"]`. No summary keyword is +//! read: a summary is prose, not a classification. + +/// The CWE GitHub and OSV use for an embedded malicious package. +pub const MALWARE_CWE: &str = "CWE-506"; + +/// `id`, any of `aliases` is a `MAL-` id, or `cwe_ids` names CWE-506 +/// (case-insensitive, surrounding whitespace ignored). +pub fn is_malware(id: &str, aliases: &[String], cwe_ids: &[String]) -> bool { + let mal = |s: &str| s.trim().to_ascii_uppercase().starts_with("MAL-"); + mal(id) + || aliases.iter().any(|a| mal(a)) + || cwe_ids + .iter() + .any(|c| c.trim().eq_ignore_ascii_case(MALWARE_CWE)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(items: &[&str]) -> Vec { + items.iter().map(ToString::to_string).collect() + } + + #[test] + fn crossenv_is_malware_by_its_cwe_alone() { + // GHSA-c2m4-w5hm-vqjw as OSV serves it: no MAL- alias. + assert!(is_malware( + "GHSA-c2m4-w5hm-vqjw", + &v(&["CVE-2017-16074"]), + &v(&["CWE-506"]) + )); + } + + #[test] + fn a_mal_id_or_alias_is_malware() { + assert!(is_malware("MAL-2026-0001", &[], &[])); + assert!(is_malware("GHSA-xxxx", &v(&["mal-2026-0002"]), &[])); + assert!(is_malware("GHSA-xxxx", &[], &v(&[" cwe-506 "]))); + } + + #[test] + fn an_ordinary_advisory_is_not() { + assert!(!is_malware( + "GHSA-37ch-88jc-xwx2", + &v(&["CVE-2024-45296"]), + &v(&["CWE-1333"]) + )); + assert!(!is_malware( + "GHSA-xxxx", + &v(&["MALWARE-NOTE"]), + &v(&["CWE-5060"]) + )); + assert!(!is_malware("", &[], &[])); + } +} diff --git a/src-tauri/contract/app-schema.sql b/src-tauri/contract/app-schema.sql index 3406a00ce..ba2aa5d74 100644 --- a/src-tauri/contract/app-schema.sql +++ b/src-tauri/contract/app-schema.sql @@ -1,7 +1,7 @@ -- 4DA app-schema contract. GENERATED, do not edit by hand. -- Regenerate: UPDATE_APP_SCHEMA_CONTRACT=1 cargo test --lib app_schema_contract -- Consumer: github.com/4DA-Systems/4da-mcp-server (pnpm run contract) --- schema_version: 127 +-- schema_version: 128 CREATE TABLE accuracy_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -853,7 +853,7 @@ CREATE TABLE osv_advisories ( modified_at TEXT, synced_at TEXT NOT NULL DEFAULT (datetime('now')), aliases TEXT, - severity_label TEXT, withdrawn_at TEXT, + severity_label TEXT, withdrawn_at TEXT, cwe_ids TEXT, UNIQUE(advisory_id, package_name, ecosystem) ); @@ -1925,4 +1925,4 @@ CREATE VIEW current_dependencies AS AND ds.ecosystem = latest.ecosystem AND ds.scanned_at = latest.latest; -INSERT INTO schema_version (version) VALUES (127); +INSERT INTO schema_version (version) VALUES (128); diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 47c867a7c..0f5afd58b 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -1488,7 +1488,7 @@ impl Database { .query_row("SELECT version FROM schema_version", [], |row| row.get(0)) .unwrap_or(1); - const TARGET_VERSION: i64 = 127; + const TARGET_VERSION: i64 = 128; // Downgrade detection: if DB schema is newer than this binary expects, // show a clear error instead of silently corrupting the schema. @@ -5914,6 +5914,40 @@ impl Database { )?; } + // Phase 128 (review of #981, parity decision 1): an advisory's + // CWE ids, so a GitHub malware advisory with no `MAL-` alias + // (crossenv GHSA-c2m4-w5hm-vqjw: CWE-506 only) reads as malware + // (`fourda_depengine::is_malware`). The column fills on the next + // sync; every ecosystem's sync is made due now, so rows stored + // before it do not stay NULL until a 6-hour window passes. + if current_version < 128 { + Self::run_versioned_migration( + &conn, + 127, + 128, + "Phase 128: osv_advisories.cwe_ids; mirror re-sync due", + |c| { + let has_column: bool = c + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('osv_advisories') WHERE name='cwe_ids'", + [], + |row| row.get::<_, i64>(0).map(|n| n > 0), + ) + .unwrap_or(false); + if !has_column { + c.execute("ALTER TABLE osv_advisories ADD COLUMN cwe_ids TEXT", [])?; + } + let due = Self::make_osv_sync_due(c)?; + info!( + target: "4da::db", + due, + "Phase 128: cwe_ids added; every advisory ecosystem re-syncs next cycle" + ); + Ok(()) + }, + )?; + } + info!(target: "4da::db", "Database schema initialized with sqlite-vec"); return Ok(()); } @@ -5922,6 +5956,16 @@ impl Database { Ok(()) } + /// Phase 128: make every ecosystem's advisory sync due on the next cycle + /// (`osv::sync::needs_sync` reads `last_synced_at`), so the mirror is + /// re-upserted with the new column. Returns rows changed. + pub(crate) fn make_osv_sync_due(c: &Connection) -> SqliteResult { + c.execute( + "UPDATE osv_sync_status SET last_synced_at = '1970-01-01 00:00:00'", + [], + ) + } + /// Phase 126 (a): turn every interest off (AD-054). Returns rows changed. pub(crate) fn turn_interests_off(c: &Connection) -> SqliteResult { let stack = crate::sources::source_class::STACK_SOURCES diff --git a/src-tauri/src/db/osv_advisories.rs b/src-tauri/src/db/osv_advisories.rs index d03bdc242..5ba544d74 100644 --- a/src-tauri/src/db/osv_advisories.rs +++ b/src-tauri/src/db/osv_advisories.rs @@ -50,6 +50,24 @@ impl Database { ) } + /// Store an advisory row's CWE ids (Phase 128), after its upsert. `None` + /// clears them (the source no longer lists any). + pub fn set_osv_advisory_cwe_ids( + &self, + advisory_id: &str, + package_name: &str, + ecosystem: &str, + cwe_ids_json: Option<&str>, + ) -> SqliteResult<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE osv_advisories SET cwe_ids = ?4 + WHERE advisory_id = ?1 AND package_name = ?2 AND ecosystem = ?3", + params![advisory_id, package_name, ecosystem, cwe_ids_json], + )?; + Ok(()) + } + /// Upsert an advisory into the local mirror, including the alias set and /// the normalised severity label (Phase 120). `COALESCE` on conflict keeps /// metadata a later sync omits. @@ -124,7 +142,7 @@ impl Database { "SELECT id, advisory_id, summary, details, package_name, ecosystem, affected_ranges, fixed_versions, severity_type, cvss_score, source_url, published_at, modified_at, withdrawn_at, synced_at, - aliases, severity_label + aliases, severity_label, cwe_ids FROM osv_advisories WHERE package_name = ?1 AND ecosystem = ?2 AND withdrawn_at IS NULL ORDER BY cvss_score DESC NULLS LAST, published_at DESC", @@ -152,7 +170,7 @@ impl Database { "SELECT id, advisory_id, summary, details, package_name, ecosystem, affected_ranges, fixed_versions, severity_type, cvss_score, source_url, published_at, modified_at, withdrawn_at, synced_at, - aliases, severity_label + aliases, severity_label, cwe_ids FROM osv_advisories WHERE withdrawn_at IS NULL AND (advisory_id = ?1 COLLATE NOCASE @@ -175,7 +193,7 @@ impl Database { "SELECT id, advisory_id, summary, details, package_name, ecosystem, affected_ranges, fixed_versions, severity_type, cvss_score, source_url, published_at, modified_at, withdrawn_at, synced_at, - aliases, severity_label + aliases, severity_label, cwe_ids FROM osv_advisories WHERE withdrawn_at IS NULL AND (advisory_id = ?1 COLLATE NOCASE @@ -193,7 +211,7 @@ impl Database { "SELECT id, advisory_id, summary, details, package_name, ecosystem, affected_ranges, fixed_versions, severity_type, cvss_score, source_url, published_at, modified_at, withdrawn_at, synced_at, - aliases, severity_label + aliases, severity_label, cwe_ids FROM osv_advisories WHERE withdrawn_at IS NULL ORDER BY cvss_score DESC NULLS LAST, published_at DESC", @@ -210,7 +228,7 @@ impl Database { "SELECT id, advisory_id, summary, details, package_name, ecosystem, affected_ranges, fixed_versions, severity_type, cvss_score, source_url, published_at, modified_at, withdrawn_at, synced_at, - aliases, severity_label + aliases, severity_label, cwe_ids FROM osv_advisories ORDER BY cvss_score DESC NULLS LAST, published_at DESC", )?; @@ -334,6 +352,10 @@ fn map_advisory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result .and_then(|s| serde_json::from_str::>(&s).ok()) .unwrap_or_default(), severity_label: row.get(16)?, + cwe_ids: row + .get::<_, Option>(17)? + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(), }) } diff --git a/src-tauri/src/evidence/stack_change_tests.rs b/src-tauri/src/evidence/stack_change_tests.rs index 462771515..1ef86be33 100644 --- a/src-tauri/src/evidence/stack_change_tests.rs +++ b/src-tauri/src/evidence/stack_change_tests.rs @@ -460,6 +460,7 @@ fn a_scratch_copy_never_covers_malware_for_an_active_project() { advisory_id: "MAL-2026-77".to_string(), summary: "Malicious code in x (npm)".to_string(), aliases: Vec::new(), + cwe_ids: Vec::new(), affected_ranges: Some(r#"[{"type":"ENUMERATED","events":["2.0.5","2.1.0"]}]"#.into()), }; let verdict = crate::osv::release_advisories::assess( diff --git a/src-tauri/src/evidence/upgrade_plan_tests.rs b/src-tauri/src/evidence/upgrade_plan_tests.rs index 33ee3ae45..7b674d438 100644 --- a/src-tauri/src/evidence/upgrade_plan_tests.rs +++ b/src-tauri/src/evidence/upgrade_plan_tests.rs @@ -501,6 +501,7 @@ fn matched(cvss: f64, installs: &[(bool, bool, bool)]) -> crate::osv::types::Mat .collect(), aliases: vec![], severity_label: None, + cwe_ids: Vec::new(), } } diff --git a/src-tauri/src/osv/fix_target_tests.rs b/src-tauri/src/osv/fix_target_tests.rs index 310c53f7e..c808aea41 100644 --- a/src-tauri/src/osv/fix_target_tests.rs +++ b/src-tauri/src/osv/fix_target_tests.rs @@ -157,6 +157,7 @@ fn matched(id: &str, instances: Vec) -> MatchedAdvisory { dependency_instances: instances, aliases: vec![], severity_label: None, + cwe_ids: Vec::new(), } } diff --git a/src-tauri/src/osv/identity.rs b/src-tauri/src/osv/identity.rs index f527bb28e..721ef9fb7 100644 --- a/src-tauri/src/osv/identity.rs +++ b/src-tauri/src/osv/identity.rs @@ -407,6 +407,7 @@ mod tests { dependency_instances: vec![], aliases: aliases.iter().map(|s| s.to_string()).collect(), severity_label: label.map(str::to_string), + cwe_ids: Vec::new(), } } diff --git a/src-tauri/src/osv/matching.rs b/src-tauri/src/osv/matching.rs index 189478619..dd3f10ee0 100644 --- a/src-tauri/src/osv/matching.rs +++ b/src-tauri/src/osv/matching.rs @@ -143,6 +143,7 @@ pub(crate) fn get_matched_advisories_with_not_compiled( dependency_instances: f.dependency_instances, aliases: advisory.aliases.clone(), severity_label: advisory.severity_label.clone(), + cwe_ids: advisory.cwe_ids.clone(), } }) .collect(); diff --git a/src-tauri/src/osv/parent_release.rs b/src-tauri/src/osv/parent_release.rs index 9bc445286..1ad00eeab 100644 --- a/src-tauri/src/osv/parent_release.rs +++ b/src-tauri/src/osv/parent_release.rs @@ -74,7 +74,7 @@ pub struct Releases { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Advisory { pub id: String, - /// A malicious-package record (OSV `MAL-` id or alias): a release it + /// A malicious-package record (`fourda_depengine::is_malware`): a release it /// affects is never clean, never a target and never "the newest". pub malware: bool, /// Stored ranges JSON; `None` or unparseable makes every decision on the @@ -83,12 +83,12 @@ pub struct Advisory { } impl Advisory { - /// From a stored id, its aliases and its ranges. - pub fn new(id: &str, aliases: &[String], ranges: Option) -> Self { - let mal = |s: &str| s.trim().to_ascii_uppercase().starts_with("MAL-"); + /// From a stored id, its aliases, its CWE ids and its ranges; malware per + /// `fourda_depengine::is_malware`. + pub fn new(id: &str, aliases: &[String], cwe_ids: &[String], ranges: Option) -> Self { Self { id: id.to_string(), - malware: mal(id) || aliases.iter().any(|a| mal(a)), + malware: fourda_depengine::is_malware(id, aliases, cwe_ids), ranges, } } @@ -121,7 +121,7 @@ impl<'a> Advisories<'a> { ecosystem, list: stored .into_iter() - .map(|a| Advisory::new(&a.advisory_id, &a.aliases, a.affected_ranges)) + .map(|a| Advisory::new(&a.advisory_id, &a.aliases, &a.cwe_ids, a.affected_ranges)) .collect(), } } diff --git a/src-tauri/src/osv/parent_release_graph_vectors.rs b/src-tauri/src/osv/parent_release_graph_vectors.rs index 009d2e0b2..681aef96d 100644 --- a/src-tauri/src/osv/parent_release_graph_vectors.rs +++ b/src-tauri/src/osv/parent_release_graph_vectors.rs @@ -66,6 +66,23 @@ fn store_advisories(db: &Database, advisories: &[&Value]) { None, None, ); + // Optional `aliases` / `cwe_ids`, stored as a sync stores them. + let list = |key: &str| { + let items: Vec<&str> = arr(a, key).into_iter().filter_map(Value::as_str).collect(); + (!items.is_empty()).then(|| json!(items).to_string()) + }; + if let Some(aliases) = list("aliases") { + let _ = db.conn.lock().execute( + "UPDATE osv_advisories SET aliases = ?2 WHERE advisory_id = ?1", + rusqlite::params![s(a, "id"), aliases], + ); + } + let _ = db.set_osv_advisory_cwe_ids( + s(a, "id"), + s(a, "package"), + "npm", + list("cwe_ids").as_deref(), + ); } } diff --git a/src-tauri/src/osv/parent_release_tests.rs b/src-tauri/src/osv/parent_release_tests.rs index cfb1eccc1..a69be2e1e 100644 --- a/src-tauri/src/osv/parent_release_tests.rs +++ b/src-tauri/src/osv/parent_release_tests.rs @@ -25,7 +25,7 @@ fn hist(pairs: &[(&str, Option<&str>)]) -> RequirementHistory { } fn adv(id: &str, ranges: Option) -> Advisory { - Advisory::new(id, &[], ranges) + Advisory::new(id, &[], &[], ranges) } fn range(intro: &str, fixed: &str) -> Option { @@ -475,6 +475,26 @@ fn par_case(par_advisories: Vec) -> Decision { decide(&par, "1.0.0", None, &own, &rows, &[], Dialect::Npm) } +#[test] +fn a_cwe_506_record_with_no_mal_alias_is_malware_too() { + // crossenv GHSA-c2m4-w5hm-vqjw as OSV serves it (2026-10-18). + let crossenv = Advisory::new( + "GHSA-c2m4-w5hm-vqjw", + &["CVE-2017-16074".to_string()], + &["CWE-506".to_string()], + Some(r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#.into()), + ); + assert!(crossenv.malware); + // An installed malicious parent is removed or replaced, never moved, and + // never "remains affected (no fix published)". + let d = par_case(vec![crossenv]); + assert_eq!( + d.rows[0].standing, + Standing::Malicious("GHSA-c2m4-w5hm-vqjw".into()) + ); + assert_eq!(d.rows[0].to, None); +} + #[test] fn a_malicious_release_is_never_named_nor_the_newest() { let mal = || vec![adv("MAL-2026-0001", range("2.0.0", "2.0.1"))]; @@ -500,6 +520,7 @@ fn a_malicious_release_is_never_named_nor_the_newest() { let aliased = Advisory::new( "GHSA-xxxx", &["MAL-2026-0002".to_string()], + &[], range("0", "9.0.0"), ); assert!(aliased.malware); diff --git a/src-tauri/src/osv/parent_release_vectors.rs b/src-tauri/src/osv/parent_release_vectors.rs index fab12f643..e425034ad 100644 --- a/src-tauri/src/osv/parent_release_vectors.rs +++ b/src-tauri/src/osv/parent_release_vectors.rs @@ -53,7 +53,12 @@ fn advisories(v: Option<&Value>) -> Vec { json!({"type": "SEMVER", "events": events}) }) .collect(); - Advisory::new(s(a, "id"), &[], Some(Value::Array(ranges).to_string())) + Advisory::new( + s(a, "id"), + &strings(a.get("aliases")), + &strings(a.get("cwe_ids")), + Some(Value::Array(ranges).to_string()), + ) }) .collect() } diff --git a/src-tauri/src/osv/reachability_tests.rs b/src-tauri/src/osv/reachability_tests.rs index c168f76d1..fa37a9493 100644 --- a/src-tauri/src/osv/reachability_tests.rs +++ b/src-tauri/src/osv/reachability_tests.rs @@ -215,6 +215,7 @@ fn advisory(eco: &str, details: &str) -> StoredAdvisory { synced_at: String::new(), aliases: Vec::new(), severity_label: Some("high".to_string()), + cwe_ids: Vec::new(), } } diff --git a/src-tauri/src/osv/release_advisories.rs b/src-tauri/src/osv/release_advisories.rs index 794746568..ce6ed1029 100644 --- a/src-tauri/src/osv/release_advisories.rs +++ b/src-tauri/src/osv/release_advisories.rs @@ -53,22 +53,17 @@ pub(crate) struct AdvisoryWindow { pub advisory_id: String, pub summary: String, pub aliases: Vec, + /// `database_specific.cwe_ids` (Phase 128). + pub cwe_ids: Vec, /// The stored OSV ranges JSON (`osv_advisories.affected_ranges`). pub affected_ranges: Option, } impl AdvisoryWindow { - /// A malicious-package record: an OSV `MAL-` id (or alias), or a GitHub - /// malware advisory, whose summary OSV writes as "Malicious code in …". + /// A malicious-package record (`fourda_depengine::is_malware`: an OSV + /// `MAL-` id or alias, or CWE-506). The summary is not read. pub(crate) fn is_malware(&self) -> bool { - let mal = |id: &str| id.trim().to_ascii_uppercase().starts_with("MAL-"); - mal(&self.advisory_id) - || self.aliases.iter().any(|a| mal(a)) - || self - .summary - .trim() - .to_ascii_lowercase() - .starts_with("malicious") + fourda_depengine::is_malware(&self.advisory_id, &self.aliases, &self.cwe_ids) } /// (inside the window, placed by a readable bound) for `version`. @@ -398,7 +393,7 @@ fn stored_advisories(db: &Database, ecosystem: &str, package: &str) -> Option Option>(&s).ok()) .unwrap_or_default(), affected_ranges: r.get(3)?, + cwe_ids: r + .get::<_, Option>(5)? + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(), }, r.get::<_, String>(4)?, )) diff --git a/src-tauri/src/osv/release_advisories_tests.rs b/src-tauri/src/osv/release_advisories_tests.rs index 19357f790..49e6755f4 100644 --- a/src-tauri/src/osv/release_advisories_tests.rs +++ b/src-tauri/src/osv/release_advisories_tests.rs @@ -21,6 +21,7 @@ fn window(id: &str, summary: &str, ranges: &str) -> AdvisoryWindow { advisory_id: id.to_string(), summary: summary.to_string(), aliases: Vec::new(), + cwe_ids: Vec::new(), affected_ranges: Some(ranges.to_string()), } } @@ -110,18 +111,24 @@ fn a_whole_package_advisory_that_already_hits_the_installed_copy_is_covered() { assert!(hit.covered_by_installed); } +/// Malware is the shared predicate (`fourda_depengine::is_malware`): a +/// `MAL-` id or alias, or CWE-506, never a summary keyword. crossenv +/// GHSA-c2m4-w5hm-vqjw carries CWE-506 and no `MAL-` alias. #[test] -fn a_ghsa_malware_summary_counts_as_malware() { - let w = window( - "GHSA-xxxx-yyyy-zzzz", - "Malicious code in foo", +fn a_ghsa_malware_record_counts_by_its_cwe_not_its_summary() { + let mut crossenv = window( + "GHSA-c2m4-w5hm-vqjw", + "crossenv is malware", r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#, ); - assert!(w.is_malware()); + crossenv.aliases = vec!["CVE-2017-16074".to_string()]; + assert!(!crossenv.is_malware(), "no CWE stored yet"); + crossenv.cwe_ids = vec!["CWE-506".to_string()]; + assert!(crossenv.is_malware()); let mut aliased = window("GHSA-a", "Something", "[]"); aliased.aliases = vec!["MAL-2026-1".to_string()]; assert!(aliased.is_malware()); - assert!(!window("GHSA-b", "Prototype pollution", "[]").is_malware()); + assert!(!window("GHSA-b", "Malicious-looking prose", "[]").is_malware()); } fn fixed_before() -> AdvisoryWindow { diff --git a/src-tauri/src/osv/sync.rs b/src-tauri/src/osv/sync.rs index 496fbe356..bf8ecfbeb 100644 --- a/src-tauri/src/osv/sync.rs +++ b/src-tauri/src/osv/sync.rs @@ -557,6 +557,8 @@ pub(super) fn store_vulnerability( vuln.severity_label().as_deref(), ) .map_err(|e| FourDaError::Internal(format!("Failed to store advisory: {e}")))?; + db.set_osv_advisory_cwe_ids(&vuln.id, &key.0, &key.1, vuln.cwe_ids_json().as_deref()) + .map_err(|e| FourDaError::Internal(format!("Failed to store advisory CWEs: {e}")))?; stored += 1; } @@ -814,4 +816,49 @@ mod tests { "an old ecosystem status must make the dependency mirror stale" ); } + + /// Phase 128 backfill: a row stored before `cwe_ids` existed is re-synced + /// with it. The migration makes every ecosystem due (a fresh status would + /// otherwise skip the sync for up to 6 h), and the next sync's upsert of + /// the same record writes its CWE ids, so crossenv GHSA-c2m4-w5hm-vqjw + /// (CWE-506, no `MAL-` alias) reads as malware. + #[test] + fn a_pre_migration_row_gets_its_cwe_ids_on_the_next_sync() { + let db = crate::test_utils::test_db(); + db.store_dependency("/project", "crossenv", Some("1.0.0"), "npm", false, None) + .unwrap(); + let record = serde_json::json!({ + "id": "GHSA-c2m4-w5hm-vqjw", + "summary": "crossenv is malware", + "aliases": ["CVE-2017-16074"], + "affected": [{"package": {"ecosystem": "npm", "name": "crossenv"}, + "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}]}]}], + "database_specific": {"cwe_ids": ["CWE-506"], "severity": "CRITICAL"} + }); + let vuln: Vulnerability = serde_json::from_value(record).unwrap(); + // As stored before Phase 128: the row exists, its CWE ids do not. + let mut seen = std::collections::HashSet::new(); + store_vulnerability(&db, &vuln, &mut seen).unwrap(); + db.set_osv_advisory_cwe_ids("GHSA-c2m4-w5hm-vqjw", "crossenv", "npm", None) + .unwrap(); + db.update_osv_sync_status("npm", 1, None).unwrap(); + let stored = || { + db.get_osv_advisories_for_package("crossenv", "npm") + .unwrap() + }; + assert!(!stored()[0].is_malware()); + assert!(!needs_sync(&db, DEFAULT_SYNC_MAX_AGE_HOURS).unwrap()); + + // The migration's step makes the sync due ... + { + let conn = db.conn.lock(); + crate::db::Database::make_osv_sync_due(&conn).unwrap(); + } + assert!(needs_sync(&db, DEFAULT_SYNC_MAX_AGE_HOURS).unwrap()); + // ... and the sync's re-upsert fills the column. + let mut seen = std::collections::HashSet::new(); + store_vulnerability(&db, &vuln, &mut seen).unwrap(); + assert_eq!(stored()[0].cwe_ids, ["CWE-506"]); + assert!(stored()[0].is_malware()); + } } diff --git a/src-tauri/src/osv/types.rs b/src-tauri/src/osv/types.rs index d7cae1894..6f4a1a4a3 100644 --- a/src-tauri/src/osv/types.rs +++ b/src-tauri/src/osv/types.rs @@ -116,6 +116,17 @@ pub struct StoredAdvisory { /// `None` when the source gives neither (RustSec maintenance notices). #[serde(default)] pub severity_label: Option, + /// `database_specific.cwe_ids` (Phase 128). Empty on rows synced before + /// the column existed, until the next sync (made due by the migration). + #[serde(default)] + pub cwe_ids: Vec, +} + +impl StoredAdvisory { + /// A malicious-package record (`fourda_depengine::is_malware`). + pub fn is_malware(&self) -> bool { + fourda_depengine::is_malware(&self.advisory_id, &self.aliases, &self.cwe_ids) + } } /// A version-confirmed copy an advisory does NOT apply to: every source file @@ -164,6 +175,9 @@ pub struct MatchedAdvisory { /// See [`StoredAdvisory::severity_label`]. #[serde(default)] pub severity_label: Option, + /// See [`StoredAdvisory::cwe_ids`]. + #[serde(default)] + pub cwe_ids: Vec, } /// Normalise a source severity label to the four-tier vocabulary every @@ -268,6 +282,22 @@ impl Vulnerability { curated.or_else(|| self.best_cvss().1.map(|s| cvss_band(s).to_string())) } + /// JSON array of `database_specific.cwe_ids`, or `None` when the source + /// lists none. + pub(crate) fn cwe_ids_json(&self) -> Option { + let ids: Vec<&str> = self + .database_specific + .as_ref()? + .get("cwe_ids")? + .as_array()? + .iter() + .filter_map(|v| v.as_str()) + .collect(); + (!ids.is_empty()) + .then(|| serde_json::to_string(&ids).ok()) + .flatten() + } + /// JSON array of alias ids, or `None` when the source lists none. pub(crate) fn aliases_json(&self) -> Option { let aliases = self.aliases.as_ref()?; diff --git a/src-tauri/src/preemption.rs b/src-tauri/src/preemption.rs index 22641d21f..d130a7c96 100644 --- a/src-tauri/src/preemption.rs +++ b/src-tauri/src/preemption.rs @@ -3175,6 +3175,7 @@ mod tests { published_at: None, aliases: vec![], severity_label: None, + cwe_ids: Vec::new(), dependency_instances: vec![ crate::osv::types::MatchedDependency { project_path: "/transitive".into(), @@ -3257,6 +3258,7 @@ mod tests { published_at: None, aliases: vec![], severity_label: None, + cwe_ids: Vec::new(), dependency_instances: vec![ copy("/navcal", "1.1.12", "1.1.21"), copy("/webhook", "1.1.18", "1.1.21"), @@ -3315,6 +3317,7 @@ mod tests { published_at: None, aliases: vec![], severity_label: Some("critical".into()), + cwe_ids: Vec::new(), dependency_instances: vec![crate::osv::types::MatchedDependency { project_path: "/paddle-webhook".into(), installed_version: Some("3.1.2".into()), diff --git a/src-tauri/src/sources/osv_tests.rs b/src-tauri/src/sources/osv_tests.rs index 8473c8a7a..34ae39332 100644 --- a/src-tauri/src/sources/osv_tests.rs +++ b/src-tauri/src/sources/osv_tests.rs @@ -23,6 +23,7 @@ fn test_matched_advisory_title_is_grounding_compatible() { dependency_instances: vec![], aliases: vec![], severity_label: None, + cwe_ids: Vec::new(), }; let item = matched_advisory_to_source_item(&m); From 601285df3936fe2e5734d3a048927f14464ed8bf Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 19 Oct 2026 00:29:40 +1000 Subject: [PATCH 2/3] fix(osv): malware also by GitHub's anchored title template; one upsert stores the CWEs (review of #992) B1: is_malware(id, aliases, cwe_ids, summary) gains a third rule: the summary starts with GitHub's malware advisory title template, "Malicious Package in " or "Malicious code in " (case-insensitive, anchored). Some GitHub malware records carry neither a MAL- alias nor a CWE: GHSA-28f8-hqmc-7ph8 ("Malicious Package in ember-power-timepicker", aliases [], cwe_ids []). Prose that merely mentions malice elsewhere is not read. The release gate's summary test is restored and the record is pinned; every caller (parent releases, the release gate, StoredAdvisory, MatchedAdvisory, the rule vectors) passes the summary. Preemption: an unrated advisory's urgency reads the shared predicate (High) instead of a MAL- id prefix only. Migration: the column is added with add_column_if_missing, and the sync is made due by setting last_synced_at back exactly the max age (osv_sync_max_age_hours), not to 1970. The sync stores cwe_ids in its one upsert (upsert_osv_advisory_record; COALESCE like the aliases) instead of a second UPDATE. malware.rs no longer claims "both engines": it names the MCP server as the mirror of the same three rules. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7 --- crates/fourda-depengine/src/lib.rs | 2 +- crates/fourda-depengine/src/malware.rs | 88 +++++++++++++++---- src-tauri/src/db/migrations.rs | 27 +++--- src-tauri/src/db/osv_advisories.rs | 62 +++++++++---- src-tauri/src/osv/parent_release.rs | 24 +++-- .../src/osv/parent_release_graph_vectors.rs | 27 +++--- src-tauri/src/osv/parent_release_tests.rs | 4 +- src-tauri/src/osv/parent_release_vectors.rs | 1 + src-tauri/src/osv/release_advisories.rs | 10 ++- src-tauri/src/osv/release_advisories_tests.rs | 41 +++++++-- src-tauri/src/osv/sync.rs | 9 +- src-tauri/src/osv/types.rs | 19 +++- src-tauri/src/preemption.rs | 63 +++++++++++-- 13 files changed, 282 insertions(+), 95 deletions(-) diff --git a/crates/fourda-depengine/src/lib.rs b/crates/fourda-depengine/src/lib.rs index c7d16e100..2f68741b0 100644 --- a/crates/fourda-depengine/src/lib.rs +++ b/crates/fourda-depengine/src/lib.rs @@ -22,7 +22,7 @@ //! - [`exposure`]: the same check for one advisory (the scorer's verdict); //! - [`lines`]: the per-project, per-release-line upgrade targets; //! - [`malware`]: whether an advisory is a malicious-package record (`MAL-` -//! id or alias, or CWE-506); +//! id or alias, CWE-506, or GitHub's anchored malware title template); //! - [`registry`]: the registry's releases and the ones it withdrew (yanked //! or deprecated), which no target may name. //! diff --git a/crates/fourda-depengine/src/malware.rs b/crates/fourda-depengine/src/malware.rs index d3d3c0949..04447c7cc 100644 --- a/crates/fourda-depengine/src/malware.rs +++ b/crates/fourda-depengine/src/malware.rs @@ -1,28 +1,43 @@ // SPDX-License-Identifier: FSL-1.1-Apache-2.0 //! Whether an advisory record is a malicious-package record: one rule for -//! every reader (parent releases, the release gate, severity), in both -//! engines. +//! every reader in this app (parent releases, the release gate, severity). +//! The MCP server (`@4da/mcp-server`) implements the same three rules; the +//! shared tests below are the contract it mirrors. //! -//! A record is malware when its id or one of its aliases is an OSV `MAL-` id, -//! or when it carries CWE-506 ("Embedded Malicious Code") in -//! `database_specific.cwe_ids`. The CWE arm is not optional: GitHub's -//! malware advisories often have no `MAL-` alias. Verified live 2026-10-18: -//! GHSA-c2m4-w5hm-vqjw ("crossenv is malware") has aliases -//! `["CVE-2017-16074"]` and `cwe_ids: ["CWE-506"]`. No summary keyword is -//! read: a summary is prose, not a classification. +//! A record is malware when ANY of these holds: +//! +//! 1. its id or one of its aliases is an OSV `MAL-` id; +//! 2. it carries CWE-506 ("Embedded Malicious Code") in +//! `database_specific.cwe_ids`. GitHub's malware advisories often have no +//! `MAL-` alias: GHSA-c2m4-w5hm-vqjw ("crossenv is malware") has aliases +//! `["CVE-2017-16074"]` and `cwe_ids: ["CWE-506"]` (verified 2026-10-18); +//! 3. its summary is GitHub's fixed malware advisory TITLE TEMPLATE, +//! anchored at the start: `Malicious Package in ` or `Malicious code in ` +//! (case-insensitive). Some GitHub malware records carry neither a `MAL-` +//! alias nor a CWE: GHSA-28f8-hqmc-7ph8 ("Malicious Package in +//! ember-power-timepicker") has `aliases: []` and `cwe_ids: []`. This is a +//! template GitHub generates, not prose: a summary that merely contains +//! "malicious" or "malware" anywhere else is NOT read as malware. /// The CWE GitHub and OSV use for an embedded malicious package. pub const MALWARE_CWE: &str = "CWE-506"; -/// `id`, any of `aliases` is a `MAL-` id, or `cwe_ids` names CWE-506 -/// (case-insensitive, surrounding whitespace ignored). -pub fn is_malware(id: &str, aliases: &[String], cwe_ids: &[String]) -> bool { +/// The anchored prefixes of GitHub's malware advisory title template, +/// lowercase (compared case-insensitively). +const TITLE_TEMPLATES: [&str; 2] = ["malicious package in ", "malicious code in "]; + +/// `id` or any of `aliases` is a `MAL-` id, `cwe_ids` names CWE-506, or +/// `summary` starts with GitHub's malware title template (all +/// case-insensitive; surrounding whitespace ignored). +pub fn is_malware(id: &str, aliases: &[String], cwe_ids: &[String], summary: &str) -> bool { let mal = |s: &str| s.trim().to_ascii_uppercase().starts_with("MAL-"); + let title = summary.trim_start().to_ascii_lowercase(); mal(id) || aliases.iter().any(|a| mal(a)) || cwe_ids .iter() .any(|c| c.trim().eq_ignore_ascii_case(MALWARE_CWE)) + || TITLE_TEMPLATES.iter().any(|t| title.starts_with(t)) } #[cfg(test)] @@ -39,15 +54,34 @@ mod tests { assert!(is_malware( "GHSA-c2m4-w5hm-vqjw", &v(&["CVE-2017-16074"]), - &v(&["CWE-506"]) + &v(&["CWE-506"]), + "crossenv is malware" + )); + } + + #[test] + fn ember_power_timepicker_is_malware_by_its_title_alone() { + // GHSA-28f8-hqmc-7ph8 as OSV serves it: no MAL- alias, no CWE. + assert!(is_malware( + "GHSA-28f8-hqmc-7ph8", + &[], + &[], + "Malicious Package in ember-power-timepicker" + )); + assert!(is_malware("GHSA-xxxx", &[], &[], "malicious code in foo")); + assert!(is_malware( + "GHSA-xxxx", + &[], + &[], + " MALICIOUS PACKAGE IN bar" )); } #[test] fn a_mal_id_or_alias_is_malware() { - assert!(is_malware("MAL-2026-0001", &[], &[])); - assert!(is_malware("GHSA-xxxx", &v(&["mal-2026-0002"]), &[])); - assert!(is_malware("GHSA-xxxx", &[], &v(&[" cwe-506 "]))); + assert!(is_malware("MAL-2026-0001", &[], &[], "")); + assert!(is_malware("GHSA-xxxx", &v(&["mal-2026-0002"]), &[], "")); + assert!(is_malware("GHSA-xxxx", &[], &v(&[" cwe-506 "]), "")); } #[test] @@ -55,13 +89,29 @@ mod tests { assert!(!is_malware( "GHSA-37ch-88jc-xwx2", &v(&["CVE-2024-45296"]), - &v(&["CWE-1333"]) + &v(&["CWE-1333"]), + "path-to-regexp outputs backtracking regular expressions" )); assert!(!is_malware( "GHSA-xxxx", &v(&["MALWARE-NOTE"]), - &v(&["CWE-5060"]) + &v(&["CWE-5060"]), + "" )); - assert!(!is_malware("", &[], &[])); + assert!(!is_malware("", &[], &[], "")); + } + + #[test] + fn prose_about_malice_is_not_the_template() { + for summary in [ + "Malicious-looking prose", + "Malicious packages can exploit foo", + "Malicious input in bar causes a crash", + "foo: Malicious Package in bar", + "Malicious Packagein baz", + "Prototype pollution allows malicious code in qux", + ] { + assert!(!is_malware("GHSA-xxxx", &[], &[], summary), "{summary}"); + } } } diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 0f5afd58b..cd1bcc041 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -5927,16 +5927,7 @@ impl Database { 128, "Phase 128: osv_advisories.cwe_ids; mirror re-sync due", |c| { - let has_column: bool = c - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('osv_advisories') WHERE name='cwe_ids'", - [], - |row| row.get::<_, i64>(0).map(|n| n > 0), - ) - .unwrap_or(false); - if !has_column { - c.execute("ALTER TABLE osv_advisories ADD COLUMN cwe_ids TEXT", [])?; - } + Self::add_column_if_missing(c, "osv_advisories", "cwe_ids", "TEXT")?; let due = Self::make_osv_sync_due(c)?; info!( target: "4da::db", @@ -5956,13 +5947,19 @@ impl Database { Ok(()) } - /// Phase 128: make every ecosystem's advisory sync due on the next cycle - /// (`osv::sync::needs_sync` reads `last_synced_at`), so the mirror is - /// re-upserted with the new column. Returns rows changed. + /// Phase 128: make every ecosystem's advisory sync due on the next cycle, + /// so the mirror is re-upserted with the new column. `osv::sync::needs_sync` + /// is due once `last_synced_at` is the max age old, so the status is set + /// back by exactly that: due now, and every surface that shows the last + /// sync still shows a plausible time (not 1970). Returns rows changed. pub(crate) fn make_osv_sync_due(c: &Connection) -> SqliteResult { + let hours = crate::osv::sync::osv_sync_max_age_hours(); c.execute( - "UPDATE osv_sync_status SET last_synced_at = '1970-01-01 00:00:00'", - [], + "UPDATE osv_sync_status + SET last_synced_at = datetime('now', '-' || ?1 || ' hours') + WHERE last_synced_at IS NOT NULL + AND last_synced_at > datetime('now', '-' || ?1 || ' hours')", + [hours], ) } diff --git a/src-tauri/src/db/osv_advisories.rs b/src-tauri/src/db/osv_advisories.rs index 5ba544d74..5a9b081a7 100644 --- a/src-tauri/src/db/osv_advisories.rs +++ b/src-tauri/src/db/osv_advisories.rs @@ -50,29 +50,54 @@ impl Database { ) } - /// Store an advisory row's CWE ids (Phase 128), after its upsert. `None` - /// clears them (the source no longer lists any). - pub fn set_osv_advisory_cwe_ids( + /// Upsert an advisory into the local mirror, including the alias set and + /// the normalised severity label (Phase 120). `COALESCE` on conflict keeps + /// metadata a later sync omits. + #[allow(clippy::too_many_arguments)] + pub fn upsert_osv_advisory_with_meta( &self, advisory_id: &str, + summary: &str, + details: Option<&str>, package_name: &str, ecosystem: &str, - cwe_ids_json: Option<&str>, + affected_ranges: Option<&str>, + fixed_versions: Option<&str>, + severity_type: Option<&str>, + cvss_score: Option, + source_url: Option<&str>, + published_at: Option<&str>, + modified_at: Option<&str>, + withdrawn_at: Option<&str>, + aliases_json: Option<&str>, + severity_label: Option<&str>, ) -> SqliteResult<()> { - let conn = self.conn.lock(); - conn.execute( - "UPDATE osv_advisories SET cwe_ids = ?4 - WHERE advisory_id = ?1 AND package_name = ?2 AND ecosystem = ?3", - params![advisory_id, package_name, ecosystem, cwe_ids_json], - )?; - Ok(()) + self.upsert_osv_advisory_record( + advisory_id, + summary, + details, + package_name, + ecosystem, + affected_ranges, + fixed_versions, + severity_type, + cvss_score, + source_url, + published_at, + modified_at, + withdrawn_at, + aliases_json, + severity_label, + None, + ) } - /// Upsert an advisory into the local mirror, including the alias set and - /// the normalised severity label (Phase 120). `COALESCE` on conflict keeps - /// metadata a later sync omits. + /// The sync's upsert: everything [`Self::upsert_osv_advisory_with_meta`] + /// stores plus the record's `database_specific.cwe_ids` (Phase 128), in + /// the same statement. Like the aliases, a later upsert without CWE ids + /// keeps the stored ones. #[allow(clippy::too_many_arguments)] - pub fn upsert_osv_advisory_with_meta( + pub fn upsert_osv_advisory_record( &self, advisory_id: &str, summary: &str, @@ -89,13 +114,14 @@ impl Database { withdrawn_at: Option<&str>, aliases_json: Option<&str>, severity_label: Option<&str>, + cwe_ids_json: Option<&str>, ) -> SqliteResult<()> { let conn = self.conn.lock(); conn.execute( "INSERT INTO osv_advisories (advisory_id, summary, details, package_name, ecosystem, affected_ranges, fixed_versions, severity_type, cvss_score, source_url, - published_at, modified_at, withdrawn_at, aliases, severity_label, synced_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, datetime('now')) + published_at, modified_at, withdrawn_at, aliases, severity_label, cwe_ids, synced_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, datetime('now')) ON CONFLICT(advisory_id, package_name, ecosystem) DO UPDATE SET summary = ?2, details = COALESCE(?3, osv_advisories.details), @@ -109,6 +135,7 @@ impl Database { withdrawn_at = ?13, aliases = COALESCE(?14, osv_advisories.aliases), severity_label = COALESCE(?15, osv_advisories.severity_label), + cwe_ids = COALESCE(?16, osv_advisories.cwe_ids), synced_at = datetime('now')", params![ advisory_id, @@ -126,6 +153,7 @@ impl Database { withdrawn_at, aliases_json, severity_label, + cwe_ids_json, ], )?; Ok(()) diff --git a/src-tauri/src/osv/parent_release.rs b/src-tauri/src/osv/parent_release.rs index 1ad00eeab..576a26ced 100644 --- a/src-tauri/src/osv/parent_release.rs +++ b/src-tauri/src/osv/parent_release.rs @@ -83,12 +83,18 @@ pub struct Advisory { } impl Advisory { - /// From a stored id, its aliases, its CWE ids and its ranges; malware per - /// `fourda_depengine::is_malware`. - pub fn new(id: &str, aliases: &[String], cwe_ids: &[String], ranges: Option) -> Self { + /// From a stored id, its aliases, its CWE ids, its summary and its + /// ranges; malware per `fourda_depengine::is_malware`. + pub fn new( + id: &str, + aliases: &[String], + cwe_ids: &[String], + summary: &str, + ranges: Option, + ) -> Self { Self { id: id.to_string(), - malware: fourda_depengine::is_malware(id, aliases, cwe_ids), + malware: fourda_depengine::is_malware(id, aliases, cwe_ids, summary), ranges, } } @@ -121,7 +127,15 @@ impl<'a> Advisories<'a> { ecosystem, list: stored .into_iter() - .map(|a| Advisory::new(&a.advisory_id, &a.aliases, &a.cwe_ids, a.affected_ranges)) + .map(|a| { + Advisory::new( + &a.advisory_id, + &a.aliases, + &a.cwe_ids, + &a.summary, + a.affected_ranges, + ) + }) .collect(), } } diff --git a/src-tauri/src/osv/parent_release_graph_vectors.rs b/src-tauri/src/osv/parent_release_graph_vectors.rs index 681aef96d..9702e0560 100644 --- a/src-tauri/src/osv/parent_release_graph_vectors.rs +++ b/src-tauri/src/osv/parent_release_graph_vectors.rs @@ -51,9 +51,13 @@ fn store_advisories(db: &Database, advisories: &[&Value]) { json!({"type": "SEMVER", "events": events}) }) .collect(); + let summary = a + .get("summary") + .and_then(Value::as_str) + .unwrap_or(s(a, "id")); let _ = db.upsert_osv_advisory( s(a, "id"), - s(a, "id"), + summary, None, s(a, "package"), "npm", @@ -66,23 +70,20 @@ fn store_advisories(db: &Database, advisories: &[&Value]) { None, None, ); - // Optional `aliases` / `cwe_ids`, stored as a sync stores them. + // Optional `summary` / `aliases` / `cwe_ids`, stored as a sync + // stores them. let list = |key: &str| { let items: Vec<&str> = arr(a, key).into_iter().filter_map(Value::as_str).collect(); (!items.is_empty()).then(|| json!(items).to_string()) }; - if let Some(aliases) = list("aliases") { - let _ = db.conn.lock().execute( - "UPDATE osv_advisories SET aliases = ?2 WHERE advisory_id = ?1", - rusqlite::params![s(a, "id"), aliases], - ); + for column in ["aliases", "cwe_ids"] { + if let Some(json) = list(column) { + let _ = db.conn.lock().execute( + &format!("UPDATE osv_advisories SET {column} = ?2 WHERE advisory_id = ?1"), + rusqlite::params![s(a, "id"), json], + ); + } } - let _ = db.set_osv_advisory_cwe_ids( - s(a, "id"), - s(a, "package"), - "npm", - list("cwe_ids").as_deref(), - ); } } diff --git a/src-tauri/src/osv/parent_release_tests.rs b/src-tauri/src/osv/parent_release_tests.rs index a69be2e1e..142748161 100644 --- a/src-tauri/src/osv/parent_release_tests.rs +++ b/src-tauri/src/osv/parent_release_tests.rs @@ -25,7 +25,7 @@ fn hist(pairs: &[(&str, Option<&str>)]) -> RequirementHistory { } fn adv(id: &str, ranges: Option) -> Advisory { - Advisory::new(id, &[], &[], ranges) + Advisory::new(id, &[], &[], "", ranges) } fn range(intro: &str, fixed: &str) -> Option { @@ -482,6 +482,7 @@ fn a_cwe_506_record_with_no_mal_alias_is_malware_too() { "GHSA-c2m4-w5hm-vqjw", &["CVE-2017-16074".to_string()], &["CWE-506".to_string()], + "crossenv is malware", Some(r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#.into()), ); assert!(crossenv.malware); @@ -521,6 +522,7 @@ fn a_malicious_release_is_never_named_nor_the_newest() { "GHSA-xxxx", &["MAL-2026-0002".to_string()], &[], + "", range("0", "9.0.0"), ); assert!(aliased.malware); diff --git a/src-tauri/src/osv/parent_release_vectors.rs b/src-tauri/src/osv/parent_release_vectors.rs index e425034ad..dd87b0779 100644 --- a/src-tauri/src/osv/parent_release_vectors.rs +++ b/src-tauri/src/osv/parent_release_vectors.rs @@ -57,6 +57,7 @@ fn advisories(v: Option<&Value>) -> Vec { s(a, "id"), &strings(a.get("aliases")), &strings(a.get("cwe_ids")), + &opt(a, "summary").unwrap_or_default(), Some(Value::Array(ranges).to_string()), ) }) diff --git a/src-tauri/src/osv/release_advisories.rs b/src-tauri/src/osv/release_advisories.rs index ce6ed1029..f6642617d 100644 --- a/src-tauri/src/osv/release_advisories.rs +++ b/src-tauri/src/osv/release_advisories.rs @@ -61,9 +61,15 @@ pub(crate) struct AdvisoryWindow { impl AdvisoryWindow { /// A malicious-package record (`fourda_depengine::is_malware`: an OSV - /// `MAL-` id or alias, or CWE-506). The summary is not read. + /// `MAL-` id or alias, CWE-506, or GitHub's anchored malware title + /// template "Malicious Package in " / "Malicious code in "). pub(crate) fn is_malware(&self) -> bool { - fourda_depengine::is_malware(&self.advisory_id, &self.aliases, &self.cwe_ids) + fourda_depengine::is_malware( + &self.advisory_id, + &self.aliases, + &self.cwe_ids, + &self.summary, + ) } /// (inside the window, placed by a readable bound) for `version`. diff --git a/src-tauri/src/osv/release_advisories_tests.rs b/src-tauri/src/osv/release_advisories_tests.rs index 49e6755f4..17a2cc402 100644 --- a/src-tauri/src/osv/release_advisories_tests.rs +++ b/src-tauri/src/osv/release_advisories_tests.rs @@ -111,24 +111,47 @@ fn a_whole_package_advisory_that_already_hits_the_installed_copy_is_covered() { assert!(hit.covered_by_installed); } -/// Malware is the shared predicate (`fourda_depengine::is_malware`): a -/// `MAL-` id or alias, or CWE-506, never a summary keyword. crossenv -/// GHSA-c2m4-w5hm-vqjw carries CWE-506 and no `MAL-` alias. #[test] -fn a_ghsa_malware_record_counts_by_its_cwe_not_its_summary() { +fn a_ghsa_malware_summary_counts_as_malware() { + let w = window( + "GHSA-xxxx-yyyy-zzzz", + "Malicious code in foo", + r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#, + ); + assert!(w.is_malware()); + let mut aliased = window("GHSA-a", "Something", "[]"); + aliased.aliases = vec!["MAL-2026-1".to_string()]; + assert!(aliased.is_malware()); + assert!(!window("GHSA-b", "Prototype pollution", "[]").is_malware()); + // The title template is anchored: prose that mentions malice is not it. + assert!(!window("GHSA-c", "Malicious-looking prose", "[]").is_malware()); +} + +/// GHSA-28f8-hqmc-7ph8 as OSV serves it: no `MAL-` alias and no CWE, only +/// GitHub's malware title. It stays malware (review of #992, B1). +#[test] +fn a_ghsa_malware_record_with_no_alias_and_no_cwe_counts_by_its_title() { + let w = window( + "GHSA-28f8-hqmc-7ph8", + "Malicious Package in ember-power-timepicker", + r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#, + ); + assert!(w.aliases.is_empty() && w.cwe_ids.is_empty()); + assert!(w.is_malware()); +} + +/// crossenv GHSA-c2m4-w5hm-vqjw: CWE-506, no `MAL-` alias, and a summary +/// outside the title template. Malware by its CWE. +#[test] +fn a_ghsa_malware_record_counts_by_its_cwe() { let mut crossenv = window( "GHSA-c2m4-w5hm-vqjw", "crossenv is malware", r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#, ); crossenv.aliases = vec!["CVE-2017-16074".to_string()]; - assert!(!crossenv.is_malware(), "no CWE stored yet"); crossenv.cwe_ids = vec!["CWE-506".to_string()]; assert!(crossenv.is_malware()); - let mut aliased = window("GHSA-a", "Something", "[]"); - aliased.aliases = vec!["MAL-2026-1".to_string()]; - assert!(aliased.is_malware()); - assert!(!window("GHSA-b", "Malicious-looking prose", "[]").is_malware()); } fn fixed_before() -> AdvisoryWindow { diff --git a/src-tauri/src/osv/sync.rs b/src-tauri/src/osv/sync.rs index bf8ecfbeb..eb75ef9d8 100644 --- a/src-tauri/src/osv/sync.rs +++ b/src-tauri/src/osv/sync.rs @@ -539,7 +539,7 @@ pub(super) fn store_vulnerability( let ranges_json = serialize_ranges(&affected); let fixed_json = extract_fixed_versions(&affected); - db.upsert_osv_advisory_with_meta( + db.upsert_osv_advisory_record( &vuln.id, summary, vuln.details.as_deref(), @@ -555,10 +555,9 @@ pub(super) fn store_vulnerability( vuln.withdrawn.as_deref(), vuln.aliases_json().as_deref(), vuln.severity_label().as_deref(), + vuln.cwe_ids_json().as_deref(), ) .map_err(|e| FourDaError::Internal(format!("Failed to store advisory: {e}")))?; - db.set_osv_advisory_cwe_ids(&vuln.id, &key.0, &key.1, vuln.cwe_ids_json().as_deref()) - .map_err(|e| FourDaError::Internal(format!("Failed to store advisory CWEs: {e}")))?; stored += 1; } @@ -839,7 +838,9 @@ mod tests { // As stored before Phase 128: the row exists, its CWE ids do not. let mut seen = std::collections::HashSet::new(); store_vulnerability(&db, &vuln, &mut seen).unwrap(); - db.set_osv_advisory_cwe_ids("GHSA-c2m4-w5hm-vqjw", "crossenv", "npm", None) + db.conn + .lock() + .execute("UPDATE osv_advisories SET cwe_ids = NULL", []) .unwrap(); db.update_osv_sync_status("npm", 1, None).unwrap(); let stored = || { diff --git a/src-tauri/src/osv/types.rs b/src-tauri/src/osv/types.rs index 6f4a1a4a3..da16aab94 100644 --- a/src-tauri/src/osv/types.rs +++ b/src-tauri/src/osv/types.rs @@ -125,7 +125,12 @@ pub struct StoredAdvisory { impl StoredAdvisory { /// A malicious-package record (`fourda_depengine::is_malware`). pub fn is_malware(&self) -> bool { - fourda_depengine::is_malware(&self.advisory_id, &self.aliases, &self.cwe_ids) + fourda_depengine::is_malware( + &self.advisory_id, + &self.aliases, + &self.cwe_ids, + &self.summary, + ) } } @@ -180,6 +185,18 @@ pub struct MatchedAdvisory { pub cwe_ids: Vec, } +impl MatchedAdvisory { + /// A malicious-package record (`fourda_depengine::is_malware`). + pub fn is_malware(&self) -> bool { + fourda_depengine::is_malware( + &self.advisory_id, + &self.aliases, + &self.cwe_ids, + &self.summary, + ) + } +} + /// Normalise a source severity label to the four-tier vocabulary every /// surface shares. GitHub says `MODERATE`; the app says `medium`. pub fn normalize_severity_label(raw: &str) -> Option { diff --git a/src-tauri/src/preemption.rs b/src-tauri/src/preemption.rs index d130a7c96..8d42096ad 100644 --- a/src-tauri/src/preemption.rs +++ b/src-tauri/src/preemption.rs @@ -532,7 +532,7 @@ fn most_urgent_tier(group: &[&crate::osv::types::MatchedAdvisory]) -> AlertUrgen Some("low") => AlertUrgency::Watch, _ => { let rep = cluster[0]; - infer_urgency_from_summary(&rep.summary, &rep.advisory_id) + infer_urgency_from_summary(rep) } }, ) @@ -1463,10 +1463,14 @@ fn dormancy_labeled_project(path: &str, liveness: &crate::evidence::ProjectLiven label } -/// Infer urgency from advisory summary text when CVSS score is absent. -fn infer_urgency_from_summary(summary: &str, advisory_id: &str) -> AlertUrgency { - let s = summary.to_lowercase(); - let id = advisory_id.to_lowercase(); +/// Infer urgency from advisory summary text when CVSS score is absent. A +/// malicious-package record (the shared `fourda_depengine::is_malware`: a +/// `MAL-` id or alias, CWE-506, or GitHub's malware title) is High first. +fn infer_urgency_from_summary(rep: &crate::osv::types::MatchedAdvisory) -> AlertUrgency { + if rep.is_malware() { + return AlertUrgency::High; + } + let s = rep.summary.to_lowercase(); let has_critical_keyword = s.contains("remote code execution") || s.contains("rce") || s.contains("arbitrary code") @@ -1487,9 +1491,6 @@ fn infer_urgency_from_summary(summary: &str, advisory_id: &str) -> AlertUrgency if has_high_keyword { return AlertUrgency::Medium; } - if id.starts_with("mal-") { - return AlertUrgency::High; - } AlertUrgency::Watch } @@ -3157,6 +3158,52 @@ mod tests { assert_eq!(alert_subject("vitest", &set(&[])), "vitest"); } + /// Review of #992: with no severity, a malicious-package record is High + /// by the shared predicate, not only a `MAL-` id: GHSA-28f8-hqmc-7ph8 + /// (title only) and crossenv GHSA-c2m4-w5hm-vqjw (CWE-506 only). + #[test] + fn an_unrated_malware_record_is_high_by_the_shared_predicate() { + let rec = |id: &str, summary: &str, cwe: &[&str]| crate::osv::types::MatchedAdvisory { + advisory_id: id.into(), + summary: summary.into(), + details: None, + package_name: "pkg".into(), + ecosystem: "npm".into(), + installed_version: Some("1.0.0".into()), + fixed_version: None, + severity_type: None, + cvss_score: None, + source_url: None, + is_version_confirmed: true, + project_paths: vec![], + published_at: None, + aliases: vec![], + severity_label: None, + cwe_ids: cwe.iter().map(ToString::to_string).collect(), + dependency_instances: vec![], + }; + for m in [ + rec( + "GHSA-28f8-hqmc-7ph8", + "Malicious Package in ember-power-timepicker", + &[], + ), + rec("GHSA-c2m4-w5hm-vqjw", "crossenv is malware", &["CWE-506"]), + rec("MAL-2026-0001", "x", &[]), + ] { + assert!( + matches!(infer_urgency_from_summary(&m), AlertUrgency::High), + "{}", + m.advisory_id + ); + } + let plain = rec("GHSA-xxxx", "Malicious-looking prose", &[]); + assert!(matches!( + infer_urgency_from_summary(&plain), + AlertUrgency::Watch + )); + } + #[test] fn osv_group_scope_prefers_direct_runtime_over_weaker_scopes() { let matched = crate::osv::types::MatchedAdvisory { From a86529dbec9808c7a6f5719fafd97df5523388b5 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 19 Oct 2026 00:54:55 +1000 Subject: [PATCH 3/3] test(releases): carry cwe_ids in the #988 AdvisoryWindow fixture (merge of #988 and #992) #988 landed a stack_change test that builds an AdvisoryWindow literal before #992 added the cwe_ids field, so the merge-queue build failed with E0063. Same fixture, empty CWE list. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7 --- src-tauri/src/evidence/stack_change_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/evidence/stack_change_tests.rs b/src-tauri/src/evidence/stack_change_tests.rs index 1ef86be33..9f80576fd 100644 --- a/src-tauri/src/evidence/stack_change_tests.rs +++ b/src-tauri/src/evidence/stack_change_tests.rs @@ -695,6 +695,7 @@ fn an_affected_release_names_every_active_project_behind_it() { advisory_id: advisory.to_string(), summary: "ReDoS".to_string(), aliases: Vec::new(), + cwe_ids: Vec::new(), affected_ranges: Some( r#"[{"type":"ENUMERATED","events":["4.1.0","4.2.1"]}]"#.to_string(), ),