use aube_codes::errors::{ERR_AUBE_SECURITY_SCANNER_FAILED, ERR_AUBE_SECURITY_SCANNER_FATAL};
use aube_codes::warnings::WARN_AUBE_SECURITY_SCANNER_FINDING;
use miette::miette;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
const SCANNER_TIMEOUT: Duration = Duration::from_secs(30);
const BUN_SHIM_SOURCE: &str = include_str!("security_scanner_js/bun_shim.mjs");
const LOADER_HOOK_SOURCE: &str = include_str!("security_scanner_js/loader_hook.mjs");
const RUNNER_SOURCE: &str = include_str!("security_scanner_js/runner.mjs");
#[derive(Debug, Clone, Serialize)]
pub struct ScannerPackage {
pub name: String,
pub version: String,
}
pub fn resolved_packages_for_scanner(graph: &aube_lockfile::LockfileGraph) -> Vec<ScannerPackage> {
let mut out: Vec<ScannerPackage> = graph
.packages
.values()
.filter(|pkg| pkg.local_source.is_none())
.map(|pkg| ScannerPackage {
name: pkg.registry_name().to_string(),
version: pkg.version.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
out.dedup_by(|a, b| a.name == b.name && a.version == b.version);
out
}
#[derive(Debug, Serialize)]
struct ScannerRequest<'a> {
packages: &'a [ScannerPackage],
}
#[derive(Debug, Deserialize)]
struct Advisory {
package: String,
level: String,
#[serde(default)]
description: String,
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Severity {
Fatal,
Warn,
Other,
}
fn classify(level: &str) -> Severity {
match level.to_ascii_lowercase().as_str() {
"fatal" => Severity::Fatal,
"warn" | "warning" => Severity::Warn,
_ => Severity::Other,
}
}
pub async fn run_scanner(
scanner_spec: &str,
cwd: &Path,
packages: &[ScannerPackage],
) -> miette::Result<()> {
if scanner_spec.is_empty() || packages.is_empty() {
return Ok(());
}
let advisories = match invoke(scanner_spec, cwd, packages).await {
Ok(a) => a,
Err(e) => {
return Err(miette!(
code = ERR_AUBE_SECURITY_SCANNER_FAILED,
"securityScanner `{scanner_spec}` could not run: {e}\n\nSet `securityScanner = \"\"` to disable the integration temporarily."
));
}
};
apply_advisories(scanner_spec, &advisories)
}
fn write_bridge_dir() -> Result<tempfile::TempDir, String> {
let dir = tempfile::Builder::new()
.prefix("aube-bun-scanner-")
.tempdir()
.map_err(|e| format!("failed to create bridge temp dir: {e}"))?;
let write = |name: &str, body: &str| -> Result<(), String> {
std::fs::write(dir.path().join(name), body)
.map_err(|e| format!("failed to write bridge file {name}: {e}"))
};
write("bun_shim.mjs", BUN_SHIM_SOURCE)?;
write("loader_hook.mjs", LOADER_HOOK_SOURCE)?;
Ok(dir)
}
async fn invoke(
scanner_spec: &str,
cwd: &Path,
packages: &[ScannerPackage],
) -> Result<Vec<Advisory>, String> {
let request = ScannerRequest { packages };
let body = serde_json::to_vec(&request)
.map_err(|e| format!("failed to encode scanner request: {e}"))?;
let bridge = write_bridge_dir()?;
let mut cmd = tokio::process::Command::new("node");
cmd.current_dir(cwd)
.arg("--experimental-strip-types")
.arg("--input-type=module")
.arg("-e")
.arg(RUNNER_SOURCE)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env("AUBE_SCANNER_SPEC", scanner_spec)
.env("AUBE_BRIDGE_DIR", bridge.path())
.env_remove("AUBE_AUTH_TOKEN")
.env_remove("NPM_TOKEN")
.env_remove("NODE_AUTH_TOKEN")
.env_remove("GITHUB_TOKEN")
.env_remove("GH_TOKEN");
let mut child = cmd.spawn().map_err(|e| {
format!("failed to spawn `node` for scanner bridge: {e}")
})?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "internal pipe error: stdin not available".to_string())?;
use tokio::io::AsyncWriteExt;
stdin
.write_all(&body)
.await
.map_err(|e| format!("failed to write request to scanner stdin: {e}"))?;
drop(stdin);
let wait = child.wait_with_output();
let output = tokio::time::timeout(SCANNER_TIMEOUT, wait)
.await
.map_err(|_| {
format!(
"scanner exceeded {} second timeout",
SCANNER_TIMEOUT.as_secs()
)
})?
.map_err(|e| format!("failed to wait for scanner subprocess: {e}"))?;
drop(bridge);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let trimmed = stderr.trim();
let snippet = if trimmed.chars().count() > 500 {
let end = trimmed
.char_indices()
.nth(500)
.map(|(i, _)| i)
.unwrap_or(trimmed.len());
format!("{}…", &trimmed[..end])
} else {
trimmed.to_string()
};
return Err(format!(
"scanner exited with status {:?}; stderr: {snippet}",
output.status.code()
));
}
serde_json::from_slice::<Vec<Advisory>>(&output.stdout)
.map_err(|e| format!("scanner stdout was not a JSON advisory array: {e}"))
}
fn apply_advisories(scanner_spec: &str, advisories: &[Advisory]) -> miette::Result<()> {
let mut fatal: Vec<&Advisory> = Vec::new();
for adv in advisories {
match classify(&adv.level) {
Severity::Fatal => fatal.push(adv),
Severity::Warn => {
let url_suffix = adv
.url
.as_deref()
.map(|u| format!(" ({u})"))
.unwrap_or_default();
tracing::warn!(
code = WARN_AUBE_SECURITY_SCANNER_FINDING,
"{}: {}{}",
adv.package,
if adv.description.is_empty() {
"flagged by securityScanner"
} else {
adv.description.as_str()
},
url_suffix
);
}
Severity::Other => {
tracing::debug!(
"securityScanner reported level={} for {}: {}",
adv.level,
adv.package,
adv.description
);
}
}
}
if fatal.is_empty() {
return Ok(());
}
let mut lines = vec![format!(
"refusing to install package(s) flagged by `securityScanner = {scanner_spec}`:"
)];
for adv in &fatal {
let url_suffix = adv
.url
.as_deref()
.map(|u| format!(" — {u}"))
.unwrap_or_default();
let body = if adv.description.is_empty() {
"(no description)".to_string()
} else {
adv.description.clone()
};
lines.push(format!(" - {}: {}{url_suffix}", adv.package, body));
}
Err(miette!(
code = ERR_AUBE_SECURITY_SCANNER_FATAL,
"{}",
lines.join("\n")
))
}
#[cfg(test)]
mod tests {
use super::*;
fn adv(package: &str, level: &str) -> Advisory {
Advisory {
package: package.to_string(),
level: level.to_string(),
description: String::new(),
url: None,
}
}
#[test]
fn classify_is_case_insensitive() {
assert_eq!(classify("FATAL"), Severity::Fatal);
assert_eq!(classify("fatal"), Severity::Fatal);
assert_eq!(classify("Warning"), Severity::Warn);
assert_eq!(classify("warn"), Severity::Warn);
assert_eq!(classify("info"), Severity::Other);
assert_eq!(classify(""), Severity::Other);
}
#[test]
fn apply_advisories_empty_is_ok() {
assert!(apply_advisories("/some/scanner", &[]).is_ok());
}
#[test]
fn apply_advisories_warn_only_does_not_block() {
let advs = vec![adv("pkg-a", "warn"), adv("pkg-b", "warning")];
assert!(apply_advisories("scanner", &advs).is_ok());
}
#[test]
fn apply_advisories_fatal_blocks() {
let advs = vec![adv("pkg-a", "warn"), adv("evil", "fatal")];
let err = apply_advisories("scanner", &advs).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("evil"), "missing package name: {msg}");
assert!(msg.contains("scanner"), "missing scanner ref: {msg}");
}
#[test]
fn unknown_severity_falls_through() {
let advs = vec![adv("pkg-a", "info"), adv("pkg-b", "trace")];
assert!(apply_advisories("scanner", &advs).is_ok());
}
fn locked(name: &str, version: &str, aliased_to: Option<&str>) -> aube_lockfile::LockedPackage {
aube_lockfile::LockedPackage {
name: name.to_string(),
version: version.to_string(),
alias_of: aliased_to.map(str::to_string),
..Default::default()
}
}
fn locked_local(name: &str, version: &str) -> aube_lockfile::LockedPackage {
let mut pkg = locked(name, version, None);
pkg.local_source = Some(aube_lockfile::LocalSource::Link("./somewhere".into()));
pkg
}
#[test]
fn resolved_packages_uses_registry_name_and_skips_local() {
let mut graph = aube_lockfile::LockfileGraph::default();
graph.packages.insert(
"lodash@4.17.21".to_string(),
locked("lodash", "4.17.21", None),
);
graph.packages.insert(
"my-alias@1.2.3".to_string(),
locked("my-alias", "1.2.3", Some("real-pkg")),
);
graph.packages.insert(
"local-thing@0.0.0".to_string(),
locked_local("local-thing", "0.0.0"),
);
let packages = resolved_packages_for_scanner(&graph);
let view: Vec<(&str, &str)> = packages
.iter()
.map(|p| (p.name.as_str(), p.version.as_str()))
.collect();
assert_eq!(
view,
vec![("lodash", "4.17.21"), ("real-pkg", "1.2.3")],
"alias should report `real-pkg`, local entry should be filtered out",
);
}
#[test]
fn resolved_packages_dedupes_peer_context_duplicates() {
let mut graph = aube_lockfile::LockfileGraph::default();
graph.packages.insert(
"styled-components@6.1.0(react@18.2.0)".to_string(),
locked("styled-components", "6.1.0", None),
);
graph.packages.insert(
"styled-components@6.1.0(react@19.0.0)".to_string(),
locked("styled-components", "6.1.0", None),
);
let packages = resolved_packages_for_scanner(&graph);
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "styled-components");
assert_eq!(packages[0].version, "6.1.0");
}
fn node_available() -> bool {
std::process::Command::new("node")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn write_simple_scanner(path: &Path, target_name: &str, level: &str) {
let body = format!(
r#"export const scanner = {{
version: '1',
async scan({{ packages }}) {{
const hits = [];
for (const p of packages) {{
if (p.name === {target:?}) {{
hits.push({{
level: {level:?},
package: p.name,
description: 'mock',
url: 'https://example.org/mock',
}});
}}
}}
return hits;
}},
}};
"#,
target = target_name,
level = level,
);
std::fs::write(path, body).unwrap();
}
#[tokio::test]
async fn end_to_end_blocks_on_fatal_advisory() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
let scanner_path = tmp.path().join("scanner.mjs");
write_simple_scanner(&scanner_path, "evil", "fatal");
let pkgs = vec![ScannerPackage {
name: "evil".to_string(),
version: "latest".to_string(),
}];
let err = run_scanner(scanner_path.to_str().unwrap(), tmp.path(), &pkgs)
.await
.unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("evil"), "missing pkg in error: {msg}");
assert!(msg.contains("mock"), "missing description in error: {msg}");
}
#[tokio::test]
async fn end_to_end_passes_on_warn_only() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
let scanner_path = tmp.path().join("scanner.mjs");
write_simple_scanner(&scanner_path, "meh", "warn");
let pkgs = vec![ScannerPackage {
name: "meh".to_string(),
version: "1.0.0".to_string(),
}];
assert!(
run_scanner(scanner_path.to_str().unwrap(), tmp.path(), &pkgs)
.await
.is_ok()
);
}
#[tokio::test]
async fn missing_scanner_fails_closed() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let pkgs = vec![ScannerPackage {
name: "lodash".to_string(),
version: "^4".to_string(),
}];
let err = run_scanner(
"/definitely/not/a/real/path/to/a/scanner.mjs",
std::path::Path::new("."),
&pkgs,
)
.await
.unwrap_err();
let chain = format!("{err:?}");
assert!(
chain.contains("ERR_AUBE_SECURITY_SCANNER_FAILED"),
"wrong code: {chain}"
);
assert!(
chain.contains("scanner.mjs"),
"missing scanner spec in error: {chain}"
);
assert!(chain.contains("disable"), "missing bootstrap hint: {chain}");
}
#[tokio::test]
async fn accepts_wrapped_advisories_response() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
let scanner_path = tmp.path().join("scanner.mjs");
std::fs::write(
&scanner_path,
r#"export const scanner = {
version: '1',
async scan({ packages }) {
return { advisories: packages.map(p => ({
level: 'fatal',
package: p.name,
description: 'wrapped',
})) };
},
};
"#,
)
.unwrap();
let pkgs = vec![ScannerPackage {
name: "any".to_string(),
version: "1".to_string(),
}];
let err = run_scanner(scanner_path.to_str().unwrap(), tmp.path(), &pkgs)
.await
.unwrap_err();
assert!(format!("{err:?}").contains("wrapped"));
}
#[tokio::test]
async fn bun_shim_exposes_env_and_semver() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
let scanner_path = tmp.path().join("scanner.mjs");
std::fs::write(
&scanner_path,
r#"import Bun from 'bun';
export const scanner = {
version: '1',
async scan({ packages }) {
const hits = [];
for (const p of packages) {
// Use Bun.semver.satisfies (the oven-sh template pattern).
// With the naive fallback the exact-equality branch fires;
// both `"1.0.0"` and `"1.0.0"` match.
if (Bun.semver.satisfies(p.version, '1.0.0')) {
// Touch Bun.env to ensure the env shim is wired.
const tag = Bun.env.AUBE_TEST_TAG ?? 'no-tag';
hits.push({
level: 'fatal',
package: p.name,
description: `matched via Bun.semver; tag=${tag}`,
});
}
}
return hits;
},
};
"#,
)
.unwrap();
let pkgs = vec![ScannerPackage {
name: "target".to_string(),
version: "1.0.0".to_string(),
}];
let err = run_scanner(scanner_path.to_str().unwrap(), tmp.path(), &pkgs)
.await
.unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("matched via Bun.semver"),
"Bun.semver.satisfies didn't fire: {msg}"
);
assert!(
msg.contains("tag=no-tag"),
"Bun.env wasn't a live object: {msg}"
);
}
#[tokio::test]
async fn bun_shim_file_reads_local_fixture() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("policy.json"), r#"{"badName":"evil"}"#).unwrap();
let scanner_path = tmp.path().join("scanner.mjs");
std::fs::write(
&scanner_path,
r#"import Bun from 'bun';
export const scanner = {
version: '1',
async scan({ packages }) {
const policy = await Bun.file('policy.json').json();
return packages
.filter(p => p.name === policy.badName)
.map(p => ({ level: 'fatal', package: p.name, description: 'matched policy' }));
},
};
"#,
)
.unwrap();
let pkgs = vec![ScannerPackage {
name: "evil".to_string(),
version: "1".to_string(),
}];
let err = run_scanner(scanner_path.to_str().unwrap(), tmp.path(), &pkgs)
.await
.unwrap_err();
assert!(format!("{err:?}").contains("matched policy"));
}
#[tokio::test]
async fn bun_shim_loads_typescript_entrypoint() {
if !node_available() {
eprintln!("skipping: `node` not on PATH");
return;
}
let probe = std::process::Command::new("node")
.arg("--experimental-strip-types")
.arg("-e")
.arg("''")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if !probe.is_ok_and(|s| s.success()) {
eprintln!("skipping: node lacks --experimental-strip-types (< 22.6)");
return;
}
let tmp = tempfile::tempdir().unwrap();
let scanner_path = tmp.path().join("scanner.ts");
std::fs::write(
&scanner_path,
r#"export const scanner = {
version: '1' as const,
async scan({ packages }: { packages: Array<{ name: string; version: string }> }) {
const hits: Array<{ level: string; package: string; description: string }> = [];
for (const p of packages) {
if (p.name === 'evil') {
hits.push({ level: 'fatal', package: p.name, description: 'ts ok' });
}
}
return hits;
},
};
"#,
)
.unwrap();
let pkgs = vec![ScannerPackage {
name: "evil".to_string(),
version: "1".to_string(),
}];
let err = run_scanner(scanner_path.to_str().unwrap(), tmp.path(), &pkgs)
.await
.unwrap_err();
assert!(format!("{err:?}").contains("ts ok"));
}
}