Skip to main content

sbox/
audit.rs

1use std::path::Path;
2use std::process::{Command, ExitCode, Stdio};
3
4use crate::cli::{AuditCommand, Cli};
5use crate::config::{LoadOptions, load_config};
6use crate::error::SboxError;
7use crate::exec::status_to_exit_code;
8
9// ── Inline audit (used by `sbox plan`) ───────────────────────────────────────
10
11pub enum InlineAuditStatus {
12    /// Audit ran and found no vulnerabilities.
13    Clean,
14    /// Audit ran and found vulnerabilities (exit code non-zero).
15    Findings,
16    /// The audit tool binary is not in PATH.
17    ToolNotFound,
18    /// The tool launched but failed for a non-audit reason.
19    Error,
20}
21
22pub struct InlineAuditResult {
23    pub pm_name: String,
24    pub tool: &'static str,
25    pub status: InlineAuditStatus,
26    /// Captured stdout + stderr, truncated to 2 000 chars.
27    pub output: String,
28}
29
30/// Run the audit tool for `pm_name` non-interactively and capture its output.
31/// Never panics or propagates errors — always returns a result the caller can display.
32pub(crate) fn run_inline(pm_name: &str, workspace_root: &Path) -> InlineAuditResult {
33    let (program, base_args, _hint) = audit_command_for(pm_name);
34
35    let result = Command::new(program)
36        .args(base_args)
37        .current_dir(workspace_root)
38        .stdin(Stdio::null())
39        .stdout(Stdio::piped())
40        .stderr(Stdio::piped())
41        .output();
42
43    match result {
44        Err(e) if e.kind() == std::io::ErrorKind::NotFound => InlineAuditResult {
45            pm_name: pm_name.to_string(),
46            tool: program,
47            status: InlineAuditStatus::ToolNotFound,
48            output: format!(
49                "`{program}` is not installed — install it and re-run `sbox plan` \
50                 or run `sbox audit` directly."
51            ),
52        },
53        Err(_) => InlineAuditResult {
54            pm_name: pm_name.to_string(),
55            tool: program,
56            status: InlineAuditStatus::Error,
57            output: format!("`{program}` could not be launched."),
58        },
59        Ok(out) => {
60            let combined = format!(
61                "{}{}",
62                String::from_utf8_lossy(&out.stdout),
63                String::from_utf8_lossy(&out.stderr)
64            );
65            let truncated = if combined.len() > 2_000 {
66                format!(
67                    "{}…  (truncated — run `sbox audit` for full output)",
68                    combined[..2_000].trim_end()
69                )
70            } else {
71                combined
72            };
73            InlineAuditResult {
74                pm_name: pm_name.to_string(),
75                tool: program,
76                status: if out.status.success() {
77                    InlineAuditStatus::Clean
78                } else {
79                    InlineAuditStatus::Findings
80                },
81                output: truncated,
82            }
83        }
84    }
85}
86
87/// `sbox audit` — scan the project's lockfile for known-malicious or vulnerable packages.
88///
89/// Delegates to the ecosystem's native audit tool and runs on the HOST (not in a sandbox)
90/// so it can reach advisory databases. This is intentional — audit only reads the lockfile
91/// and queries read-only advisory APIs; it does not execute package code.
92pub fn execute(cli: &Cli, command: &AuditCommand) -> Result<ExitCode, SboxError> {
93    let loaded = load_config(&LoadOptions {
94        workspace: cli.workspace.clone(),
95        config: cli.config.clone(),
96    })?;
97
98    let pm_name = loaded
99        .config
100        .package_manager
101        .as_ref()
102        .map(|pm| pm.name.as_str())
103        .unwrap_or_else(|| detect_pm_from_workspace(&loaded.workspace_root));
104
105    let (program, base_args, install_hint) = audit_command_for(pm_name);
106
107    let mut child = Command::new(program);
108    child.args(base_args);
109    child.args(&command.extra_args);
110    child.current_dir(&loaded.workspace_root);
111    child.stdin(Stdio::inherit());
112    child.stdout(Stdio::inherit());
113    child.stderr(Stdio::inherit());
114
115    match child.status() {
116        Ok(status) => Ok(status_to_exit_code(status)),
117        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
118            eprintln!("sbox audit: `{program}` not found.");
119            eprintln!("{install_hint}");
120            Ok(ExitCode::from(127))
121        }
122        Err(source) => Err(SboxError::CommandSpawn {
123            program: program.to_string(),
124            source,
125        }),
126    }
127}
128
129/// Returns `(program, base_args, install_hint)` for the given package manager.
130fn audit_command_for(pm_name: &str) -> (&'static str, &'static [&'static str], &'static str) {
131    match pm_name {
132        "npm" => (
133            "npm",
134            &["audit"] as &[&str],
135            "npm is required. Install Node.js from https://nodejs.org",
136        ),
137        "yarn" => (
138            "yarn",
139            &["npm", "audit"],
140            "yarn is required. Install from https://yarnpkg.com",
141        ),
142        "pnpm" => ("pnpm", &["audit"], "pnpm is required: npm install -g pnpm"),
143        "bun" => (
144            // bun does not have a native audit command; delegate to npm audit which can read
145            // package-lock.json or bun.lock
146            "npm",
147            &["audit"],
148            "npm is required for bun audit. Install Node.js from https://nodejs.org",
149        ),
150        "uv" | "pip" | "poetry" => (
151            "pip-audit",
152            &[] as &[&str],
153            "pip-audit is required: pip install pip-audit  or  uv tool install pip-audit",
154        ),
155        "cargo" => (
156            "cargo",
157            &["audit"],
158            "cargo-audit is required: cargo install cargo-audit",
159        ),
160        "go" => (
161            "govulncheck",
162            &["./..."],
163            "govulncheck is required: go install golang.org/x/vuln/cmd/govulncheck@latest",
164        ),
165        _ => (
166            "npm",
167            &["audit"],
168            "unknown package manager; defaulting to npm audit",
169        ),
170    }
171}
172
173/// Detect the package manager from lockfiles present in the workspace root.
174/// Fallback when no `package_manager:` section is configured.
175pub(crate) fn detect_pm_from_workspace(root: &Path) -> &'static str {
176    if root.join("package-lock.json").exists() || root.join("npm-shrinkwrap.json").exists() {
177        return "npm";
178    }
179    if root.join("yarn.lock").exists() {
180        return "yarn";
181    }
182    if root.join("pnpm-lock.yaml").exists() {
183        return "pnpm";
184    }
185    if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
186        return "bun";
187    }
188    if root.join("uv.lock").exists() {
189        return "uv";
190    }
191    if root.join("poetry.lock").exists() {
192        return "poetry";
193    }
194    if root.join("requirements.txt").exists() {
195        return "pip";
196    }
197    if root.join("Cargo.lock").exists() {
198        return "cargo";
199    }
200    if root.join("go.sum").exists() {
201        return "go";
202    }
203    "npm"
204}