use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "aegis-scan",
about = "Supply-chain security scanner for npm packages",
version,
after_help = "Examples:\n aegis check axios\n aegis check axios@1.7.0\n aegis check @scope/pkg@1.0.0\n aegis check lodash --json\n aegis scan .\n aegis scan ./my-project --skip-dev\n aegis install axios express\n aegis install --force\n aegis install"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(long, global = true, conflicts_with = "sarif")]
pub json: bool,
#[arg(long, global = true, conflicts_with = "json")]
pub sarif: bool,
#[arg(long, short, global = true)]
pub verbose: bool,
#[arg(long, global = true)]
pub no_cache: bool,
#[arg(long, global = true)]
pub rules: Option<PathBuf>,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long = "ignore-rule", global = true)]
pub ignore_rules: Vec<String>,
}
#[derive(Subcommand)]
pub enum Commands {
Check {
package: String,
#[arg(long)]
compare: Option<String>,
#[arg(long)]
deep: bool,
},
Scan {
path: PathBuf,
#[arg(long)]
skip_dev: bool,
},
Install {
packages: Vec<String>,
#[arg(long)]
force: bool,
#[arg(long)]
skip_dev: bool,
},
Cache {
#[command(subcommand)]
action: CacheCommands,
},
}
#[derive(Subcommand)]
pub enum CacheCommands {
Clear,
}
pub fn parse_package_specifier(spec: &str) -> (String, Option<String>) {
if let Some(scoped) = spec.strip_prefix('@') {
if let Some(at_pos) = scoped.find('@') {
if scoped[..at_pos].contains('/') {
let name = format!("@{}", &scoped[..at_pos]);
let version = scoped[at_pos + 1..].to_string();
return (name, Some(version));
}
}
(spec.to_string(), None)
} else {
match spec.split_once('@') {
Some((name, version)) => (name.to_string(), Some(version.to_string())),
None => (spec.to_string(), None),
}
}
}
pub fn collect_dependencies(
project_path: &std::path::Path,
skip_dev: bool,
) -> Result<Vec<(String, String)>> {
let pkg_path = project_path.join("package.json");
let raw = std::fs::read_to_string(&pkg_path)
.with_context(|| format!("could not read {}", pkg_path.display()))?;
let pkg: serde_json::Value =
serde_json::from_str(&raw).context("failed to parse package.json")?;
let mut deps: Vec<(String, String)> = Vec::new();
if let Some(obj) = pkg.get("dependencies").and_then(|v| v.as_object()) {
for (name, ver) in obj {
deps.push((name.clone(), ver.as_str().unwrap_or("latest").to_string()));
}
}
if !skip_dev {
if let Some(obj) = pkg.get("devDependencies").and_then(|v| v.as_object()) {
for (name, ver) in obj {
deps.push((name.clone(), ver.as_str().unwrap_or("latest").to_string()));
}
}
}
deps.sort_by(|a, b| a.0.cmp(&b.0));
Ok(deps)
}
pub fn clean_version_spec(spec: &str) -> Option<String> {
let trimmed = spec
.trim_start_matches('^')
.trim_start_matches('~')
.trim_start_matches(">=")
.trim_start_matches('=')
.trim();
if trimmed.is_empty() || trimmed == "*" || trimmed.contains("||") || trimmed.contains(' ') {
return None;
}
Some(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_unscoped_no_version() {
let (name, ver) = parse_package_specifier("axios");
assert_eq!(name, "axios");
assert_eq!(ver, None);
}
#[test]
fn parse_unscoped_with_version() {
let (name, ver) = parse_package_specifier("axios@1.7.0");
assert_eq!(name, "axios");
assert_eq!(ver, Some("1.7.0".to_string()));
}
#[test]
fn parse_scoped_no_version() {
let (name, ver) = parse_package_specifier("@scope/pkg");
assert_eq!(name, "@scope/pkg");
assert_eq!(ver, None);
}
#[test]
fn parse_scoped_with_version() {
let (name, ver) = parse_package_specifier("@scope/pkg@1.0.0");
assert_eq!(name, "@scope/pkg");
assert_eq!(ver, Some("1.0.0".to_string()));
}
#[test]
fn clean_version_spec_caret() {
assert_eq!(clean_version_spec("^4.18.0"), Some("4.18.0".to_string()));
}
#[test]
fn clean_version_spec_tilde() {
assert_eq!(clean_version_spec("~1.2.3"), Some("1.2.3".to_string()));
}
#[test]
fn clean_version_spec_star() {
assert_eq!(clean_version_spec("*"), None);
}
#[test]
fn clean_version_spec_range() {
assert_eq!(clean_version_spec(">=1.0.0 <2.0.0"), None);
}
#[test]
fn clean_version_spec_exact() {
assert_eq!(clean_version_spec("1.0.0"), Some("1.0.0".to_string()));
}
}