use aube_codes::errors::{
ERR_AUBE_ADVISORY_CHECK_FAILED, ERR_AUBE_LOW_DOWNLOAD_PACKAGE, ERR_AUBE_MALICIOUS_PACKAGE,
};
use aube_codes::warnings::{
WARN_AUBE_ADVISORY_CHECK_FAILED, WARN_AUBE_LOW_DOWNLOAD_PACKAGE,
WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
};
use aube_registry::osv_mirror::OsvMirror;
use aube_registry::supply_chain::{
DownloadCount, MaliciousAdvisory, advisory_url, fetch_malicious_advisories,
fetch_malicious_advisories_versioned, fetch_weekly_downloads_with,
};
use aube_settings::resolved::{AdvisoryCheck, AdvisoryCheckOnInstall};
use miette::miette;
use std::io::{BufRead, IsTerminal, Write};
pub async fn run_gates(
names: &[String],
advisory_check: AdvisoryCheck,
low_download_threshold: u64,
allow_low_downloads: bool,
allowed_unpopular_globs: &[String],
) -> miette::Result<()> {
if names.is_empty() {
return Ok(());
}
let client = match aube_registry::supply_chain::build_probe_client() {
Ok(c) => c,
Err(e) => {
if matches!(advisory_check, AdvisoryCheck::Off) {
tracing::debug!(
"supply-chain probe client init failed; OSV is off, skipping all gates: {e}"
);
return Ok(());
}
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client init failed: {e}"
);
if matches!(advisory_check, AdvisoryCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client could not be initialised and `advisoryCheck = required` is set: {e}"
));
}
return Ok(());
}
};
osv_gate(&client, names, advisory_check).await?;
if !allow_low_downloads && low_download_threshold > 0 {
let patterns = compile_allowed_unpopular(allowed_unpopular_globs);
let gated: Vec<String> = names
.iter()
.filter(|n| !patterns.iter().any(|p| p.matches(n)))
.cloned()
.collect();
if !gated.is_empty() {
downloads_gate(&client, &gated, low_download_threshold).await?;
}
}
Ok(())
}
pub async fn run_post_resolve_osv_routing(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
fresh_resolution: bool,
osv_transitive_check: bool,
advisory_check: AdvisoryCheck,
advisory_check_on_install: AdvisoryCheckOnInstall,
advisory_check_every_install: bool,
) -> miette::Result<()> {
let needs_live_api = osv_transitive_check || advisory_check_every_install || fresh_resolution;
if needs_live_api {
if !matches!(advisory_check, AdvisoryCheck::Off) {
run_transitive_osv_gate(cwd, graph, advisory_check).await?;
}
} else if !matches!(advisory_check_on_install, AdvisoryCheckOnInstall::Off) {
run_transitive_osv_gate_via_mirror(cwd, graph, advisory_check_on_install).await?;
}
Ok(())
}
pub async fn run_transitive_osv_gate(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
policy: AdvisoryCheck,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheck::Off) {
return Ok(());
}
let pairs = transitive_registry_pairs(cwd, graph);
if pairs.is_empty() {
return Ok(());
}
let client = match aube_registry::supply_chain::build_probe_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client init failed: {e}"
);
if matches!(policy, AdvisoryCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client could not be initialised and `advisoryCheck = required` is set: {e}"
));
}
return Ok(());
}
};
osv_gate_versioned(&client, &pairs, policy).await
}
pub async fn run_transitive_osv_gate_via_mirror(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
policy: AdvisoryCheckOnInstall,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheckOnInstall::Off) {
return Ok(());
}
let pairs = transitive_registry_pairs(cwd, graph);
if pairs.is_empty() {
return Ok(());
}
let Some(cache_dir) = aube_store::dirs::cache_dir() else {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror cache dir unavailable (HOME/XDG_CACHE_HOME unset); skipping install-time advisory check"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror cache dir unavailable and `advisoryCheckOnInstall = required` is set"
));
}
return Ok(());
};
let mirror = OsvMirror::open(&cache_dir);
let client = match OsvMirror::build_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror probe client init failed: {e}"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror probe client could not be initialised and `advisoryCheckOnInstall = required` is set: {e}"
));
}
return Ok(());
}
};
if let Err(e) = mirror.refresh_if_stale_default(&client).await {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror refresh failed: {e}"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror refresh failed and `advisoryCheckOnInstall = required` is set: {e}"
));
}
}
let hits = match mirror.lookup_advisories_versioned(&pairs) {
Ok(hits) => hits,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror lookup failed: {e}"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror lookup failed and `advisoryCheckOnInstall = required` is set: {e}"
));
}
return Ok(());
}
};
if hits.is_empty() {
return Ok(());
}
Err(miette!(
code = ERR_AUBE_MALICIOUS_PACKAGE,
"{}",
format_malicious_message(
"refusing to install malicious package(s):",
&hits,
"Set `advisoryCheckOnInstall = off` to bypass (not recommended).",
),
))
}
pub fn lockfile_has_new_picks(
cwd: &std::path::Path,
prior: Option<&aube_lockfile::LockfileGraph>,
resolved: &aube_lockfile::LockfileGraph,
) -> bool {
use std::collections::HashSet;
let npm_config = aube_registry::config::NpmConfig::load(cwd);
let prior_pairs: HashSet<(&str, &str)> = prior
.map(|g| {
g.packages
.values()
.filter(|p| p.local_source.is_none())
.map(|p| (p.registry_name(), p.version.as_str()))
.collect()
})
.unwrap_or_default();
resolved
.packages
.values()
.filter(|p| p.local_source.is_none())
.filter(|p| npm_config.is_public_npmjs(p.registry_name()))
.any(|p| !prior_pairs.contains(&(p.registry_name(), p.version.as_str())))
}
fn transitive_registry_pairs(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
) -> Vec<(String, String)> {
let npm_config = aube_registry::config::NpmConfig::load(cwd);
let mut pairs: Vec<(String, String)> = graph
.packages
.values()
.filter(|pkg| pkg.local_source.is_none())
.filter(|pkg| npm_config.is_public_npmjs(pkg.registry_name()))
.map(|pkg| (pkg.registry_name().to_string(), pkg.version.clone()))
.collect();
pairs.sort();
pairs.dedup();
pairs
}
fn compile_allowed_unpopular(raw: &[String]) -> Vec<glob::Pattern> {
raw.iter()
.filter_map(|p| match glob::Pattern::new(p) {
Ok(pat) => Some(pat),
Err(e) => {
tracing::warn!("ignoring malformed allowedUnpopularPackages entry `{p}`: {e}");
None
}
})
.collect()
}
async fn osv_gate(
client: &reqwest::Client,
names: &[String],
policy: AdvisoryCheck,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheck::Off) {
return Ok(());
}
handle_osv_result(
fetch_malicious_advisories(client, names).await,
policy,
"refusing to add malicious package(s):",
)
}
async fn osv_gate_versioned(
client: &reqwest::Client,
pairs: &[(String, String)],
policy: AdvisoryCheck,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheck::Off) {
return Ok(());
}
handle_osv_result(
fetch_malicious_advisories_versioned(client, pairs).await,
policy,
"refusing to install malicious package(s):",
)
}
fn handle_osv_result(
result: Result<Vec<MaliciousAdvisory>, aube_registry::supply_chain::SupplyChainError>,
policy: AdvisoryCheck,
refusal_header: &str,
) -> miette::Result<()> {
match result {
Ok(hits) if hits.is_empty() => Ok(()),
Ok(hits) => Err(miette!(
code = ERR_AUBE_MALICIOUS_PACKAGE,
"{}",
format_malicious_message(
refusal_header,
&hits,
"Set `advisoryCheck = off` to bypass (not recommended).",
),
)),
Err(e) => {
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"OSV advisory check failed: {e}"
);
if matches!(policy, AdvisoryCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV advisory check failed and `advisoryCheck = required` is set: {e}"
));
}
Ok(())
}
}
}
fn format_malicious_message(header: &str, hits: &[MaliciousAdvisory], footer: &str) -> String {
let mut lines = vec![header.to_string()];
for hit in hits {
let display_name = match &hit.version {
Some(v) => format!("{}@{}", hit.package, v),
None => hit.package.clone(),
};
lines.push(format!(
" - {} ({}: {})",
display_name,
hit.advisory_id,
advisory_url(&hit.advisory_id),
));
}
lines.push(String::new());
lines.push(footer.to_string());
lines.join("\n")
}
async fn downloads_gate(
client: &reqwest::Client,
names: &[String],
threshold: u64,
) -> miette::Result<()> {
let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
let mut set: tokio::task::JoinSet<(String, Result<DownloadCount, _>)> =
tokio::task::JoinSet::new();
for name in names {
let client = client.clone();
let name = name.clone();
set.spawn(async move {
let result = fetch_weekly_downloads_with(&client, &name).await;
(name, result)
});
}
let mut by_name: std::collections::HashMap<String, _> =
std::collections::HashMap::with_capacity(names.len());
while let Some(joined) = set.join_next().await {
let (name, result) = match joined {
Ok(pair) => pair,
Err(e) => {
tracing::debug!("downloads probe task join failed: {e}");
continue;
}
};
by_name.insert(name, result);
}
for name in names {
let Some(result) = by_name.remove(name) else {
continue;
};
let count = match result {
Ok(c) => c,
Err(e) => {
tracing::debug!("downloads probe failed for {name}: {e}");
continue;
}
};
let DownloadCount::Known(weekly) = count else {
continue;
};
if weekly >= threshold {
continue;
}
tracing::warn!(
code = WARN_AUBE_LOW_DOWNLOAD_PACKAGE,
"{name}: {weekly} weekly downloads (threshold: {threshold})"
);
if !interactive {
return Err(miette!(
code = ERR_AUBE_LOW_DOWNLOAD_PACKAGE,
"refusing to add {name}: only {weekly} weekly downloads (threshold: {threshold}). Pass --allow-low-downloads to bypass, or set `lowDownloadThreshold = 0`."
));
}
if !prompt_continue(name, weekly, threshold)? {
return Err(miette!(
code = ERR_AUBE_LOW_DOWNLOAD_PACKAGE,
"user aborted `aube add {name}`"
));
}
}
Ok(())
}
fn prompt_continue(name: &str, weekly: u64, threshold: u64) -> miette::Result<bool> {
let mut stderr = std::io::stderr().lock();
writeln!(stderr, " ⚠ {name} looks suspicious:").ok();
writeln!(
stderr,
" • {weekly} downloads last week (threshold: {threshold})"
)
.ok();
write!(stderr, " Continue adding {name}? [y/N] ").ok();
stderr.flush().ok();
drop(stderr);
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line).map_err(|e| {
miette!(
code = ERR_AUBE_LOW_DOWNLOAD_PACKAGE,
"failed to read confirmation: {e}"
)
})?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn osv_gate_off_skips_network() {
let client = aube_registry::supply_chain::build_probe_client()
.expect("probe client builder shouldn't fail in tests");
let names = vec!["lodash".to_string()];
assert!(osv_gate(&client, &names, AdvisoryCheck::Off).await.is_ok());
}
#[tokio::test]
async fn run_gates_no_op_on_empty() {
assert!(
run_gates(&[], AdvisoryCheck::Required, 1000, false, &[])
.await
.is_ok()
);
}
#[test]
fn compile_allowed_unpopular_drops_invalid_patterns() {
let pats = compile_allowed_unpopular(&[
"@myorg/*".to_string(),
"[unterminated".to_string(),
"internal-*".to_string(),
]);
assert_eq!(pats.len(), 2);
assert!(pats.iter().any(|p| p.matches("@myorg/foo")));
assert!(pats.iter().any(|p| p.matches("internal-thing")));
assert!(!pats.iter().any(|p| p.matches("public-pkg")));
}
#[test]
fn compile_allowed_unpopular_scope_glob_matches_only_in_scope() {
let pats = compile_allowed_unpopular(&["@myorg/*".to_string()]);
assert!(pats[0].matches("@myorg/utils"));
assert!(pats[0].matches("@myorg/nested-name"));
assert!(!pats[0].matches("@otherorg/utils"));
assert!(!pats[0].matches("myorg-utils"));
}
fn registry_pkg(name: &str, version: &str) -> aube_lockfile::LockedPackage {
aube_lockfile::LockedPackage {
name: name.to_string(),
version: version.to_string(),
..Default::default()
}
}
#[test]
fn transitive_registry_pairs_skips_local_source_entries() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let mut linked = registry_pkg("@workspace/util", "1.0.0");
linked.local_source = Some(aube_lockfile::LocalSource::Link("../util".into()));
packages.insert("@workspace/util@1.0.0".to_string(), linked);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let pairs = transitive_registry_pairs(tmp.path(), &graph);
assert_eq!(pairs, vec![("lodash".to_string(), "4.17.21".to_string())],);
}
#[test]
fn transitive_registry_pairs_dedups_by_registry_name_and_version() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let mut aliased = registry_pkg("my-alias", "4.17.21");
aliased.alias_of = Some("lodash".to_string());
packages.insert("my-alias@4.17.21".to_string(), aliased);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let pairs = transitive_registry_pairs(tmp.path(), &graph);
assert_eq!(pairs, vec![("lodash".to_string(), "4.17.21".to_string())],);
}
#[test]
fn transitive_registry_pairs_keeps_distinct_versions_of_one_name() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"ansi-regex@3.0.1".to_string(),
registry_pkg("ansi-regex", "3.0.1"),
);
packages.insert(
"ansi-regex@6.2.1".to_string(),
registry_pkg("ansi-regex", "6.2.1"),
);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let pairs = transitive_registry_pairs(tmp.path(), &graph);
assert_eq!(
pairs,
vec![
("ansi-regex".to_string(), "3.0.1".to_string()),
("ansi-regex".to_string(), "6.2.1".to_string()),
],
);
}
#[test]
fn format_malicious_message_includes_version_when_present() {
let hits = vec![
MaliciousAdvisory {
package: "ansi-regex".to_string(),
advisory_id: "MAL-2025-46966".to_string(),
version: Some("6.2.1".to_string()),
},
MaliciousAdvisory {
package: "evil".to_string(),
advisory_id: "MAL-9999".to_string(),
version: None,
},
];
let msg = format_malicious_message("header:", &hits, "footer.");
assert!(msg.contains("ansi-regex@6.2.1"));
assert!(msg.contains("MAL-2025-46966"));
assert!(msg.contains("- evil ("), "name-only hit keeps bare name");
assert!(msg.starts_with("header:"));
assert!(msg.ends_with("footer."));
}
#[test]
fn lockfile_drift_no_prior_lockfile_is_drift_when_resolved_has_entries() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let resolved = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(lockfile_has_new_picks(tmp.path(), None, &resolved));
}
#[test]
fn lockfile_drift_no_prior_with_only_workspace_entries_is_not_drift() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
let mut linked = registry_pkg("@workspace/util", "1.0.0");
linked.local_source = Some(aube_lockfile::LocalSource::Link("../util".into()));
packages.insert("@workspace/util@1.0.0".to_string(), linked);
let resolved = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), None, &resolved));
}
#[test]
fn lockfile_drift_empty_resolve_and_no_prior_is_not_drift() {
let resolved = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), None, &resolved));
}
#[test]
fn lockfile_drift_fully_pinned_is_not_drift() {
use std::collections::BTreeMap;
let mut prior_packages = BTreeMap::new();
prior_packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let prior = aube_lockfile::LockfileGraph {
packages: prior_packages.clone(),
..Default::default()
};
let resolved = aube_lockfile::LockfileGraph {
packages: prior_packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), Some(&prior), &resolved));
}
#[test]
fn lockfile_drift_new_version_is_drift() {
use std::collections::BTreeMap;
let mut prior_packages = BTreeMap::new();
prior_packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let prior = aube_lockfile::LockfileGraph {
packages: prior_packages,
..Default::default()
};
let mut resolved_packages = BTreeMap::new();
resolved_packages.insert(
"lodash@4.17.22".to_string(),
registry_pkg("lodash", "4.17.22"),
);
let resolved = aube_lockfile::LockfileGraph {
packages: resolved_packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(lockfile_has_new_picks(tmp.path(), Some(&prior), &resolved));
}
#[test]
fn lockfile_drift_ignores_local_source_entries() {
use std::collections::BTreeMap;
let mut resolved_packages = BTreeMap::new();
let mut linked = registry_pkg("@workspace/util", "1.0.0");
linked.local_source = Some(aube_lockfile::LocalSource::Link("../util".into()));
resolved_packages.insert("@workspace/util@1.0.0".to_string(), linked);
let resolved = aube_lockfile::LockfileGraph {
packages: resolved_packages,
..Default::default()
};
let prior = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), Some(&prior), &resolved));
}
#[tokio::test]
async fn run_transitive_osv_gate_off_skips_network() {
let graph = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
run_transitive_osv_gate(tmp.path(), &graph, AdvisoryCheck::Off)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_transitive_osv_gate_via_mirror_off_short_circuits() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
run_transitive_osv_gate_via_mirror(tmp.path(), &graph, AdvisoryCheckOnInstall::Off,)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_transitive_osv_gate_via_mirror_empty_graph_is_noop() {
let graph = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
run_transitive_osv_gate_via_mirror(tmp.path(), &graph, AdvisoryCheckOnInstall::On,)
.await
.is_ok()
);
}
}