Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/fourda-depengine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -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;
117 changes: 117 additions & 0 deletions crates/fourda-depengine/src/malware.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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}");
}
}
}
6 changes: 3 additions & 3 deletions src-tauri/contract/app-schema.sql
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
);

Expand Down Expand Up @@ -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);
43 changes: 42 additions & 1 deletion src-tauri/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(());
}
Expand All @@ -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<usize> {
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<usize> {
let stack = crate::sources::source_class::STACK_SOURCES
Expand Down
64 changes: 57 additions & 7 deletions src-tauri/src/db/osv_advisories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
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),
Expand All @@ -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,
Expand All @@ -108,6 +153,7 @@ impl Database {
withdrawn_at,
aliases_json,
severity_label,
cwe_ids_json,
],
)?;
Ok(())
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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",
)?;
Expand Down Expand Up @@ -334,6 +380,10 @@ fn map_advisory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredAdvisory>
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default(),
severity_label: row.get(16)?,
cwe_ids: row
.get::<_, Option<String>>(17)?
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default(),
})
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/evidence/stack_change_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/evidence/upgrade_plan_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/osv/fix_target_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ fn matched(id: &str, instances: Vec<MatchedDependency>) -> MatchedAdvisory {
dependency_instances: instances,
aliases: vec![],
severity_label: None,
cwe_ids: Vec::new(),
}
}

Expand Down
Loading
Loading