use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use inspektr::models::Severity;
use inspektr::oci::build_auth;
use inspektr::pipeline;
use inspektr::vuln::report;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(
name = "inspektr",
version,
about = "A software composition analysis tool",
long_about = None,
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Sbom {
target: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, default_value = "cyclonedx")]
format: String,
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
},
Vuln {
target: Option<String>,
#[arg(long)]
sbom: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
format: Option<String>,
#[arg(long)]
fail_on: Option<String>,
#[arg(long)]
db: Option<PathBuf>,
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
},
Db {
#[command(subcommand)]
subcommand: DbCommands,
},
}
#[derive(Debug, Subcommand)]
enum DbCommands {
Update {
#[arg(long, default_value = inspektr::db::DEFAULT_DB_REGISTRY)]
registry: String,
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
},
#[cfg(feature = "db-admin")]
Build {
#[arg(long)]
ecosystem: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
skip_failed: bool,
#[arg(long)]
nvd_from_github: bool,
},
#[cfg(feature = "db-admin")]
Push {
registry: String,
#[arg(long)]
db: Option<PathBuf>,
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
password_stdin: bool,
},
Clean,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Sbom {
target,
output,
format,
username,
password,
password_stdin,
} => {
let auth = resolve_cli_auth(username.as_deref(), password.as_deref(), password_stdin)?;
cmd_sbom(&target, output.as_deref(), &format, &auth)
}
Commands::Vuln {
target,
sbom,
output,
format,
fail_on,
db,
username,
password,
password_stdin,
} => {
let auth = resolve_cli_auth(username.as_deref(), password.as_deref(), password_stdin)?;
cmd_vuln(
target.as_deref(),
sbom.as_deref(),
output.as_deref(),
format.as_deref(),
fail_on.as_deref(),
db.as_deref(),
&auth,
)
}
Commands::Db { subcommand } => match subcommand {
DbCommands::Update {
registry,
username,
password,
password_stdin,
} => {
let auth =
resolve_cli_auth(username.as_deref(), password.as_deref(), password_stdin)?;
cmd_db_update(®istry, &auth)
}
#[cfg(feature = "db-admin")]
DbCommands::Build {
ecosystem,
output,
skip_failed,
nvd_from_github,
} => cmd_db_build(
ecosystem.as_deref(),
output.as_deref(),
skip_failed,
nvd_from_github,
),
#[cfg(feature = "db-admin")]
DbCommands::Push {
registry,
db,
username,
password,
password_stdin,
} => {
let auth =
resolve_cli_auth(username.as_deref(), password.as_deref(), password_stdin)?;
cmd_db_push(®istry, db.as_deref(), &auth)
}
DbCommands::Clean => cmd_db_clean(),
},
}
}
fn resolve_cli_auth(
username: Option<&str>,
password: Option<&str>,
password_stdin: bool,
) -> Result<inspektr::oci::RegistryAuth> {
let resolved_password = if password_stdin {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("Failed to read password from stdin")?;
Some(buf.trim().to_string())
} else {
password.map(|p| p.to_string())
};
Ok(build_auth(username, resolved_password.as_deref()))
}
fn cmd_sbom(
target: &str,
output: Option<&std::path::Path>,
format: &str,
auth: &inspektr::oci::RegistryAuth,
) -> Result<()> {
let bytes = pipeline::generate_sbom_bytes(target, format, auth)
.with_context(|| format!("Failed to generate SBOM for '{}'", target))?;
match output {
Some(path) => {
std::fs::write(path, &bytes)
.with_context(|| format!("Failed to write SBOM to '{}'", path.display()))?;
eprintln!("SBOM written to {}", path.display());
}
None => {
let text = String::from_utf8(bytes).context("SBOM output is not valid UTF-8")?;
print!("{}", text);
}
}
Ok(())
}
fn cmd_vuln(
target: Option<&str>,
sbom: Option<&std::path::Path>,
output: Option<&std::path::Path>,
format: Option<&str>,
fail_on: Option<&str>,
db: Option<&std::path::Path>,
auth: &inspektr::oci::RegistryAuth,
) -> Result<()> {
if target.is_none() && sbom.is_none() {
bail!("No target specified. Usage: inspektr vuln <TARGET> or inspektr vuln --sbom <FILE>");
}
let db_path = match db {
Some(p) => p.to_path_buf(),
None => pipeline::default_db_path(),
};
let sbom_str = sbom.map(|p| p.to_string_lossy().into_owned());
let scan_report = pipeline::scan_and_report(target, sbom_str.as_deref(), &db_path, auth)
.with_context(|| "Failed to scan for vulnerabilities")?;
let fmt = match format {
Some(f) => f,
None => {
if output.is_some() {
"json"
} else {
"table"
}
}
};
let rendered = match fmt {
"table" => report::render_report_table(&scan_report),
"json" => report::render_report_json(&scan_report)?,
other => bail!("Unknown format: '{}'. Supported: table, json", other),
};
match output {
Some(path) => {
std::fs::write(path, &rendered)
.with_context(|| format!("Failed to write report to '{}'", path.display()))?;
eprintln!("Report written to {}", path.display());
}
None => {
print!("{}", rendered);
}
}
if let Some(severity_str) = fail_on {
let threshold = parse_severity_flag(severity_str)?;
if report::has_severity_at_or_above_report(&scan_report, threshold) {
bail!(
"Found vulnerabilities at or above severity '{}'",
severity_str
);
}
}
Ok(())
}
fn cmd_db_update(registry: &str, auth: &inspektr::oci::RegistryAuth) -> Result<()> {
let db_path = pipeline::default_db_path();
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory '{}'", parent.display()))?;
}
eprintln!("Pulling vulnerability database from {} …", registry);
inspektr::oci::pull::pull_artifact(registry, &db_path, auth)
.with_context(|| format!("Failed to pull database from '{}'", registry))?;
eprintln!("Database updated at {}", db_path.display());
Ok(())
}
fn cmd_db_clean() -> Result<()> {
let db_path = pipeline::default_db_path();
if db_path.exists() {
std::fs::remove_file(&db_path)
.with_context(|| format!("Failed to delete database at '{}'", db_path.display()))?;
eprintln!("Deleted vulnerability database at {}", db_path.display());
} else {
eprintln!("No database found at {}", db_path.display());
}
Ok(())
}
#[cfg(feature = "db-admin")]
fn cmd_db_build(
ecosystem: Option<&str>,
output: Option<&std::path::Path>,
skip_failed: bool,
nvd_from_github: bool,
) -> Result<()> {
use inspektr::db::store::VulnStore;
use inspektr::db::{normalize_ecosystem, vuln_sources, vuln_sources_github_nvd};
let db_path = output
.map(|p| p.to_path_buf())
.unwrap_or_else(pipeline::default_db_path);
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory '{}'", parent.display()))?;
}
let ecosystem = match ecosystem {
Some(eco) => {
let normalized = normalize_ecosystem(eco).ok_or_else(|| {
anyhow::anyhow!(
"Unknown ecosystem: '{}'. Supported: Go, npm, PyPI, Maven",
eco
)
})?;
Some(normalized)
}
None => None,
};
if db_path.exists() {
std::fs::remove_file(&db_path).with_context(|| {
format!(
"Failed to remove existing database at '{}'",
db_path.display()
)
})?;
}
eprintln!("Building vulnerability database at {} …", db_path.display());
let db_str = db_path.to_string_lossy();
let mut store =
VulnStore::create(&db_str).context("Failed to create vulnerability database")?;
let mut total = 0;
let mut failures: Vec<String> = Vec::new();
let sources = if nvd_from_github {
eprintln!("Using GitHub NVD mirror (fkie-cad/nvd-json-data-feeds)");
vuln_sources_github_nvd()
} else {
vuln_sources()
};
for source in sources {
match source.import(&mut store, ecosystem) {
Ok(count) => total += count,
Err(e) => {
let msg = format!("{}: {}", source.name(), e);
if skip_failed {
eprintln!("Warning: {} import failed: {}", source.name(), e);
} else {
eprintln!("Error: {} import failed: {}", source.name(), e);
failures.push(msg);
}
}
}
}
if !failures.is_empty() {
eprintln!(
"Built database with {} vulnerabilities before failure.",
total
);
bail!(
"Database build failed. {} source(s) failed:\n {}",
failures.len(),
failures.join("\n ")
);
}
store.enrich_none_severity();
store.save().context("Failed to save database")?;
eprintln!("Built database with {} total vulnerabilities.", total);
Ok(())
}
#[cfg(feature = "db-admin")]
fn cmd_db_push(
registry: &str,
db: Option<&std::path::Path>,
auth: &inspektr::oci::RegistryAuth,
) -> Result<()> {
use inspektr::oci::push::push_artifact;
let db_path = db
.map(|p| p.to_path_buf())
.unwrap_or_else(pipeline::default_db_path);
if !db_path.exists() {
anyhow::bail!(
"Database not found at {}. Run `inspektr db build` first.",
db_path.display()
);
}
eprintln!("Pushing database to {}...", registry);
push_artifact(
registry,
&db_path,
"application/vnd.inspektr.db.v1+bincode",
auth,
)
.with_context(|| format!("Failed to push database to '{}'", registry))?;
eprintln!("Database pushed to {}", registry);
Ok(())
}
fn parse_severity_flag(s: &str) -> Result<Severity> {
match s.to_lowercase().as_str() {
"none" => Ok(Severity::None),
"low" => Ok(Severity::Low),
"medium" => Ok(Severity::Medium),
"high" => Ok(Severity::High),
"critical" => Ok(Severity::Critical),
other => bail!(
"Unknown severity '{}'; accepted values: none, low, medium, high, critical",
other
),
}
}