use crate::client::ClientType;
use crate::rules::{Confidence, ParseEnumError, RuleSeverity, Severity};
use crate::run::EffectiveConfig;
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Terminal,
Json,
Sarif,
Html,
Markdown,
}
impl std::str::FromStr for OutputFormat {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"terminal" | "term" => Ok(OutputFormat::Terminal),
"json" => Ok(OutputFormat::Json),
"sarif" => Ok(OutputFormat::Sarif),
"html" => Ok(OutputFormat::Html),
"markdown" | "md" => Ok(OutputFormat::Markdown),
_ => Err(ParseEnumError::invalid("OutputFormat", s)),
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BadgeFormat {
Url,
#[default]
Markdown,
Html,
}
impl std::str::FromStr for BadgeFormat {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"url" => Ok(BadgeFormat::Url),
"markdown" | "md" => Ok(BadgeFormat::Markdown),
"html" => Ok(BadgeFormat::Html),
_ => Err(ParseEnumError::invalid("BadgeFormat", s)),
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScanType {
#[default]
Skill,
Hook,
Mcp,
Command,
Rules,
Docker,
Dependency,
Subagent,
Plugin,
}
impl std::str::FromStr for ScanType {
type Err = ParseEnumError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"skill" => Ok(ScanType::Skill),
"hook" => Ok(ScanType::Hook),
"mcp" => Ok(ScanType::Mcp),
"command" | "cmd" => Ok(ScanType::Command),
"rules" => Ok(ScanType::Rules),
"docker" => Ok(ScanType::Docker),
"dependency" | "dep" | "deps" => Ok(ScanType::Dependency),
"subagent" | "agent" => Ok(ScanType::Subagent),
"plugin" => Ok(ScanType::Plugin),
_ => Err(ParseEnumError::invalid("ScanType", s)),
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum HookAction {
Init {
#[arg(default_value = ".")]
path: PathBuf,
},
Remove {
#[arg(default_value = ".")]
path: PathBuf,
},
}
#[derive(Args, Debug, Clone)]
pub struct CheckArgs {
#[arg(required_unless_present_any = ["remote", "remote_list", "awesome_claude_code", "all_clients", "client", "compare"])]
pub paths: Vec<PathBuf>,
#[arg(short = 'c', long = "config", value_name = "FILE")]
pub config: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "client"])]
pub all_clients: bool,
#[arg(long, value_enum, conflicts_with_all = ["remote", "remote_list", "awesome_claude_code", "all_clients"])]
pub client: Option<ClientType>,
#[arg(long, value_name = "URL")]
pub remote: Option<String>,
#[arg(long, default_value = "HEAD")]
pub git_ref: String,
#[arg(long, env = "GITHUB_TOKEN", value_name = "TOKEN")]
pub remote_auth: Option<String>,
#[arg(long, conflicts_with = "remote", value_name = "FILE")]
pub remote_list: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["remote", "remote_list"])]
pub awesome_claude_code: bool,
#[arg(long, default_value = "4")]
pub parallel_clones: usize,
#[arg(long)]
pub badge: bool,
#[arg(long, value_enum, default_value_t = BadgeFormat::Markdown)]
pub badge_format: BadgeFormat,
#[arg(long)]
pub summary: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Terminal)]
pub format: OutputFormat,
#[arg(short = 'S', long)]
pub strict: bool,
#[arg(long)]
pub warn_only: bool,
#[arg(long, value_enum)]
pub min_severity: Option<Severity>,
#[arg(long, value_enum)]
pub min_rule_severity: Option<RuleSeverity>,
#[arg(short = 't', long = "type", value_enum, default_value_t = ScanType::Skill)]
pub scan_type: ScanType,
#[arg(long = "no-recursive")]
pub no_recursive: bool,
#[arg(long)]
pub ci: bool,
#[arg(long, value_enum)]
pub min_confidence: Option<Confidence>,
#[arg(long)]
pub skip_comments: bool,
#[arg(long)]
pub strict_secrets: bool,
#[arg(long)]
pub fix_hint: bool,
#[arg(long)]
pub compact: bool,
#[arg(short, long)]
pub watch: bool,
#[arg(long)]
pub malware_db: Option<PathBuf>,
#[arg(long)]
pub no_malware_scan: bool,
#[arg(long)]
pub cve_db: Option<PathBuf>,
#[arg(long)]
pub no_cve_scan: bool,
#[arg(long)]
pub custom_rules: Option<PathBuf>,
#[arg(long)]
pub baseline: bool,
#[arg(long)]
pub check_drift: bool,
#[arg(short, long)]
pub output: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub save_baseline: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub baseline_file: Option<PathBuf>,
#[arg(long, num_args = 2, value_names = ["PATH1", "PATH2"])]
pub compare: Option<Vec<PathBuf>>,
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub fix_dry_run: bool,
#[arg(long)]
pub hook_mode: bool,
#[arg(long)]
pub pin: bool,
#[arg(long)]
pub pin_verify: bool,
#[arg(long)]
pub pin_update: bool,
#[arg(long)]
pub pin_force: bool,
#[arg(long)]
pub ignore_pin: bool,
#[arg(long)]
pub deep_scan: bool,
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
#[arg(long, value_name = "NAME")]
pub save_profile: Option<String>,
#[arg(long)]
pub report_fp: bool,
#[arg(long)]
pub report_fp_dry_run: bool,
#[arg(long, value_name = "URL")]
pub report_fp_endpoint: Option<String>,
#[arg(long)]
pub no_telemetry: bool,
#[arg(long)]
pub sbom: bool,
#[arg(long, value_name = "FORMAT")]
pub sbom_format: Option<String>,
#[arg(long)]
pub sbom_npm: bool,
#[arg(long)]
pub sbom_cargo: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ProxyArgs {
#[arg(long, default_value = "8080")]
pub port: u16,
#[arg(long, required = true, value_name = "HOST:PORT")]
pub target: String,
#[arg(long)]
pub tls: bool,
#[arg(long)]
pub block: bool,
#[arg(long, value_name = "FILE")]
pub log: Option<PathBuf>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Commands {
Init {
#[arg(default_value = ".cc-audit.yaml")]
path: PathBuf,
},
Check(Box<CheckArgs>),
Hook {
#[command(subcommand)]
action: HookAction,
},
Serve,
Proxy(ProxyArgs),
}
#[derive(Parser, Debug, Default)]
#[command(
name = "cc-audit",
version,
about = "Security auditor for Claude Code skills, hooks, and MCP servers",
long_about = "cc-audit scans Claude Code skills, hooks, and MCP servers for security vulnerabilities before installation."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(short, long, global = true)]
pub verbose: bool,
}
impl Default for CheckArgs {
fn default() -> Self {
Self {
paths: Vec::new(),
config: None,
all_clients: false,
client: None,
remote: None,
git_ref: "HEAD".to_string(),
remote_auth: None,
remote_list: None,
awesome_claude_code: false,
parallel_clones: 4,
badge: false,
badge_format: BadgeFormat::Markdown,
summary: false,
format: OutputFormat::Terminal,
strict: false,
warn_only: false,
min_severity: None,
min_rule_severity: None,
scan_type: ScanType::Skill,
no_recursive: false,
ci: false,
min_confidence: None,
skip_comments: false,
strict_secrets: false,
fix_hint: false,
compact: false,
watch: false,
malware_db: None,
no_malware_scan: false,
cve_db: None,
no_cve_scan: false,
custom_rules: None,
baseline: false,
check_drift: false,
output: None,
save_baseline: None,
baseline_file: None,
compare: None,
fix: false,
fix_dry_run: false,
hook_mode: false,
pin: false,
pin_verify: false,
pin_update: false,
pin_force: false,
ignore_pin: false,
deep_scan: false,
profile: None,
save_profile: None,
report_fp: false,
report_fp_dry_run: false,
report_fp_endpoint: None,
no_telemetry: false,
sbom: false,
sbom_format: None,
sbom_npm: false,
sbom_cargo: false,
}
}
}
impl CheckArgs {
pub fn for_scan(&self, paths: Vec<PathBuf>, effective: &EffectiveConfig) -> Self {
Self {
paths,
config: self.config.clone(),
remote: None,
git_ref: effective.git_ref.clone(),
remote_auth: effective.remote_auth.clone(),
remote_list: None,
awesome_claude_code: false,
parallel_clones: effective.parallel_clones,
badge: effective.badge,
badge_format: effective.badge_format,
summary: effective.summary,
format: effective.format,
strict: effective.strict,
warn_only: effective.warn_only,
min_severity: effective.min_severity,
min_rule_severity: effective.min_rule_severity,
scan_type: effective.scan_type,
no_recursive: false,
ci: effective.ci,
min_confidence: Some(effective.min_confidence),
watch: false,
skip_comments: effective.skip_comments,
strict_secrets: effective.strict_secrets,
fix_hint: effective.fix_hint,
compact: effective.compact,
no_malware_scan: effective.no_malware_scan,
cve_db: effective.cve_db.as_ref().map(PathBuf::from),
no_cve_scan: effective.no_cve_scan,
malware_db: effective.malware_db.as_ref().map(PathBuf::from),
custom_rules: effective.custom_rules.as_ref().map(PathBuf::from),
baseline: false,
check_drift: false,
output: effective.output.as_ref().map(PathBuf::from),
save_baseline: None,
baseline_file: self.baseline_file.clone(),
compare: None,
fix: false,
fix_dry_run: false,
pin: false,
pin_verify: false,
pin_update: false,
pin_force: false,
ignore_pin: false,
deep_scan: effective.deep_scan,
profile: self.profile.clone(),
save_profile: None,
all_clients: false,
client: None,
report_fp: false,
report_fp_dry_run: false,
report_fp_endpoint: None,
no_telemetry: self.no_telemetry,
sbom: false,
sbom_format: None,
sbom_npm: false,
sbom_cargo: false,
hook_mode: false,
}
}
pub fn for_batch_scan(&self, paths: Vec<PathBuf>, effective: &EffectiveConfig) -> Self {
let mut args = self.for_scan(paths, effective);
args.badge = false;
args.badge_format = BadgeFormat::Markdown;
args.summary = false;
args.format = OutputFormat::Terminal;
args.ci = false;
args.fix_hint = false;
args.output = None;
args.baseline_file = None;
args
}
}
impl Default for ProxyArgs {
fn default() -> Self {
Self {
port: 8080,
target: String::new(),
tls: false,
block: false,
log: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Confidence, RuleSeverity, Severity};
use clap::CommandFactory;
#[test]
fn test_cli_valid() {
Cli::command().debug_assert();
}
#[test]
fn test_no_args_succeeds() {
let cli = Cli::try_parse_from(["cc-audit"]).unwrap();
assert!(cli.command.is_none());
}
#[test]
fn test_parse_init_subcommand() {
let cli = Cli::try_parse_from(["cc-audit", "init"]).unwrap();
assert!(matches!(cli.command, Some(Commands::Init { .. })));
}
#[test]
fn test_parse_init_subcommand_with_path() {
let cli = Cli::try_parse_from(["cc-audit", "init", "custom-config.yaml"]).unwrap();
if let Some(Commands::Init { path }) = cli.command {
assert_eq!(path.to_str().unwrap(), "custom-config.yaml");
} else {
panic!("Expected Init command");
}
}
#[test]
fn test_parse_check_subcommand() {
let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert_eq!(args.paths.len(), 1);
assert!(!args.strict);
assert!(!args.no_recursive); } else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_multiple_paths() {
let cli = Cli::try_parse_from(["cc-audit", "check", "./skill1/", "./skill2/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert_eq!(args.paths.len(), 2);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_format_json() {
let cli =
Cli::try_parse_from(["cc-audit", "check", "--format", "json", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.format, OutputFormat::Json));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_strict_mode() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--strict", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.strict);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_no_recursive() {
let cli =
Cli::try_parse_from(["cc-audit", "check", "--no-recursive", "./skills/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.no_recursive);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_format_sarif() {
let cli =
Cli::try_parse_from(["cc-audit", "check", "--format", "sarif", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.format, OutputFormat::Sarif));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_type_hook() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--type", "hook", "./settings.json"])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.scan_type, ScanType::Hook));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_type_mcp() {
let cli =
Cli::try_parse_from(["cc-audit", "check", "--type", "mcp", "./mcp.json"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.scan_type, ScanType::Mcp));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_ci_mode() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--ci", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.ci);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_verbose() {
let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
assert!(cli.verbose);
}
#[test]
fn test_parse_check_all_options() {
let cli = Cli::try_parse_from([
"cc-audit", "check", "--format", "json", "--strict", "--type", "hook", "--ci",
"./path/",
])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.format, OutputFormat::Json));
assert!(args.strict);
assert!(matches!(args.scan_type, ScanType::Hook));
assert!(args.ci);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_default_values() {
let cli = Cli::try_parse_from(["cc-audit", "check", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.format, OutputFormat::Terminal));
assert!(matches!(args.scan_type, ScanType::Skill));
assert!(!args.strict);
assert!(!args.no_recursive);
assert!(!args.ci);
assert!(args.min_confidence.is_none());
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_min_confidence() {
let cli = Cli::try_parse_from([
"cc-audit",
"check",
"--min-confidence",
"tentative",
"./skill/",
])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(matches!(args.min_confidence, Some(Confidence::Tentative)));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_skip_comments() {
let cli =
Cli::try_parse_from(["cc-audit", "check", "--skip-comments", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.skip_comments);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_watch() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--watch", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.watch);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_watch_short() {
let cli = Cli::try_parse_from(["cc-audit", "check", "-w", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.watch);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_malware_db() {
let cli = Cli::try_parse_from([
"cc-audit",
"check",
"--malware-db",
"./custom.json",
"./skill/",
])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.malware_db.is_some());
assert_eq!(args.malware_db.unwrap().to_str().unwrap(), "./custom.json");
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_custom_rules() {
let cli = Cli::try_parse_from([
"cc-audit",
"check",
"--custom-rules",
"./rules.yaml",
"./skill/",
])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.custom_rules.is_some());
assert_eq!(args.custom_rules.unwrap().to_str().unwrap(), "./rules.yaml");
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_config_option() {
let cli =
Cli::try_parse_from(["cc-audit", "check", "-c", "custom.yaml", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert_eq!(args.config.unwrap().to_str().unwrap(), "custom.yaml");
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_warn_only() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--warn-only", "./skill/"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.warn_only);
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_min_severity() {
let cli = Cli::try_parse_from([
"cc-audit",
"check",
"--min-severity",
"critical",
"./skill/",
])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert_eq!(args.min_severity, Some(Severity::Critical));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_min_rule_severity() {
let cli = Cli::try_parse_from([
"cc-audit",
"check",
"--min-rule-severity",
"error",
"./skill/",
])
.unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert_eq!(args.min_rule_severity, Some(RuleSeverity::Error));
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_all_clients() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--all-clients"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert!(args.all_clients);
assert!(args.paths.is_empty());
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_parse_check_client_claude() {
let cli = Cli::try_parse_from(["cc-audit", "check", "--client", "claude"]).unwrap();
if let Some(Commands::Check(args)) = cli.command {
assert_eq!(args.client, Some(ClientType::Claude));
assert!(args.paths.is_empty());
} else {
panic!("Expected Check command");
}
}
#[test]
fn test_check_all_clients_conflicts_with_client() {
let result =
Cli::try_parse_from(["cc-audit", "check", "--all-clients", "--client", "claude"]);
assert!(result.is_err());
}
#[test]
fn test_parse_hook_init() {
let cli = Cli::try_parse_from(["cc-audit", "hook", "init"]).unwrap();
if let Some(Commands::Hook { action }) = cli.command {
assert!(matches!(action, HookAction::Init { .. }));
} else {
panic!("Expected Hook command");
}
}
#[test]
fn test_parse_hook_init_with_path() {
let cli = Cli::try_parse_from(["cc-audit", "hook", "init", "./repo/"]).unwrap();
if let Some(Commands::Hook { action }) = cli.command {
if let HookAction::Init { path } = action {
assert_eq!(path.to_str().unwrap(), "./repo/");
} else {
panic!("Expected HookAction::Init");
}
} else {
panic!("Expected Hook command");
}
}
#[test]
fn test_parse_hook_remove() {
let cli = Cli::try_parse_from(["cc-audit", "hook", "remove"]).unwrap();
if let Some(Commands::Hook { action }) = cli.command {
assert!(matches!(action, HookAction::Remove { .. }));
} else {
panic!("Expected Hook command");
}
}
#[test]
fn test_parse_hook_remove_with_path() {
let cli = Cli::try_parse_from(["cc-audit", "hook", "remove", "./repo/"]).unwrap();
if let Some(Commands::Hook { action }) = cli.command {
if let HookAction::Remove { path } = action {
assert_eq!(path.to_str().unwrap(), "./repo/");
} else {
panic!("Expected HookAction::Remove");
}
} else {
panic!("Expected Hook command");
}
}
#[test]
fn test_parse_serve() {
let cli = Cli::try_parse_from(["cc-audit", "serve"]).unwrap();
assert!(matches!(cli.command, Some(Commands::Serve)));
}
#[test]
fn test_parse_proxy() {
let cli = Cli::try_parse_from(["cc-audit", "proxy", "--target", "localhost:9000"]).unwrap();
if let Some(Commands::Proxy(args)) = cli.command {
assert_eq!(args.target, "localhost:9000");
assert_eq!(args.port, 8080); assert!(!args.tls);
assert!(!args.block);
} else {
panic!("Expected Proxy command");
}
}
#[test]
fn test_parse_proxy_with_all_options() {
let cli = Cli::try_parse_from([
"cc-audit",
"proxy",
"--target",
"localhost:9000",
"--port",
"3000",
"--tls",
"--block",
"--log",
"proxy.log",
])
.unwrap();
if let Some(Commands::Proxy(args)) = cli.command {
assert_eq!(args.target, "localhost:9000");
assert_eq!(args.port, 3000);
assert!(args.tls);
assert!(args.block);
assert_eq!(args.log.unwrap().to_str().unwrap(), "proxy.log");
} else {
panic!("Expected Proxy command");
}
}
#[test]
fn test_proxy_requires_target() {
let result = Cli::try_parse_from(["cc-audit", "proxy"]);
assert!(result.is_err());
}
#[test]
fn test_verbose_global_flag() {
let cli = Cli::try_parse_from(["cc-audit", "-v", "check", "./skill/"]).unwrap();
assert!(cli.verbose);
let cli2 = Cli::try_parse_from(["cc-audit", "check", "-v", "./skill/"]).unwrap();
assert!(cli2.verbose);
let cli3 = Cli::try_parse_from(["cc-audit", "check", "./skill/", "-v"]).unwrap();
assert!(cli3.verbose);
}
}