diff --git a/crates/fourda-depengine/src/lib.rs b/crates/fourda-depengine/src/lib.rs index aa6ab3cdc..2f68741b0 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, 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. //! @@ -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..04447c7cc --- /dev/null +++ b/crates/fourda-depengine/src/malware.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: FSL-1.1-Apache-2.0 +//! Whether an advisory record is a malicious-package record: one rule for +//! 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 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"; + +/// 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)] +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"]), + "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 "]), "")); + } + + #[test] + fn an_ordinary_advisory_is_not() { + assert!(!is_malware( + "GHSA-37ch-88jc-xwx2", + &v(&["CVE-2024-45296"]), + &v(&["CWE-1333"]), + "path-to-regexp outputs backtracking regular expressions" + )); + assert!(!is_malware( + "GHSA-xxxx", + &v(&["MALWARE-NOTE"]), + &v(&["CWE-5060"]), + "" + )); + 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/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..cd1bcc041 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,31 @@ 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| { + Self::add_column_if_missing(c, "osv_advisories", "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 +5947,22 @@ impl Database { Ok(()) } + /// 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 = datetime('now', '-' || ?1 || ' hours') + WHERE last_synced_at IS NOT NULL + AND last_synced_at > datetime('now', '-' || ?1 || ' hours')", + [hours], + ) + } + /// 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..5a9b081a7 100644 --- a/src-tauri/src/db/osv_advisories.rs +++ b/src-tauri/src/db/osv_advisories.rs @@ -71,13 +71,57 @@ impl Database { withdrawn_at: Option<&str>, aliases_json: Option<&str>, severity_label: Option<&str>, + ) -> SqliteResult<()> { + 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, + ) + } + + /// 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_record( + &self, + advisory_id: &str, + summary: &str, + details: Option<&str>, + package_name: &str, + ecosystem: &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>, + 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), @@ -91,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, @@ -108,6 +153,7 @@ impl Database { withdrawn_at, aliases_json, severity_label, + cwe_ids_json, ], )?; Ok(()) @@ -124,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 package_name = ?1 AND ecosystem = ?2 AND withdrawn_at IS NULL ORDER BY cvss_score DESC NULLS LAST, published_at DESC", @@ -152,7 +198,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 +221,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 +239,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 +256,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 +380,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..9f80576fd 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( @@ -694,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(), ), 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..576a26ced 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,18 @@ 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, 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: mal(id) || aliases.iter().any(|a| mal(a)), + 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.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 009d2e0b2..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,6 +70,20 @@ fn store_advisories(db: &Database, advisories: &[&Value]) { None, None, ); + // 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()) + }; + 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], + ); + } + } } } diff --git a/src-tauri/src/osv/parent_release_tests.rs b/src-tauri/src/osv/parent_release_tests.rs index cfb1eccc1..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 { @@ -475,6 +475,27 @@ 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()], + "crossenv is malware", + 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 +521,8 @@ 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..dd87b0779 100644 --- a/src-tauri/src/osv/parent_release_vectors.rs +++ b/src-tauri/src/osv/parent_release_vectors.rs @@ -53,7 +53,13 @@ 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")), + &opt(a, "summary").unwrap_or_default(), + 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..f6642617d 100644 --- a/src-tauri/src/osv/release_advisories.rs +++ b/src-tauri/src/osv/release_advisories.rs @@ -53,22 +53,23 @@ 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, CWE-506, or GitHub's anchored malware title + /// template "Malicious Package in " / "Malicious code in "). 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, + &self.summary, + ) } /// (inside the window, placed by a readable bound) for `version`. @@ -398,7 +399,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..17a2cc402 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()), } } @@ -122,6 +123,35 @@ fn a_ghsa_malware_summary_counts_as_malware() { 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()]; + crossenv.cwe_ids = vec!["CWE-506".to_string()]; + assert!(crossenv.is_malware()); } fn fixed_before() -> AdvisoryWindow { diff --git a/src-tauri/src/osv/sync.rs b/src-tauri/src/osv/sync.rs index 496fbe356..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,6 +555,7 @@ 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}")))?; @@ -814,4 +815,51 @@ 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.conn + .lock() + .execute("UPDATE osv_advisories SET cwe_ids = NULL", []) + .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..da16aab94 100644 --- a/src-tauri/src/osv/types.rs +++ b/src-tauri/src/osv/types.rs @@ -116,6 +116,22 @@ 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, + &self.summary, + ) + } } /// A version-confirmed copy an advisory does NOT apply to: every source file @@ -164,6 +180,21 @@ pub struct MatchedAdvisory { /// See [`StoredAdvisory::severity_label`]. #[serde(default)] pub severity_label: Option, + /// See [`StoredAdvisory::cwe_ids`]. + #[serde(default)] + 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 @@ -268,6 +299,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..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 { @@ -3175,6 +3222,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 +3305,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 +3364,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);