Skip to main content

openclaw_scan/
cli.rs

1//! Command-line argument definitions (clap derive API).
2
3use std::path::PathBuf;
4
5use clap::{Parser, ValueEnum};
6
7// ── Severity filter ───────────────────────────────────────────────────────────
8
9/// Minimum severity level for displayed findings.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
11pub enum SeverityFilter {
12    Critical,
13    High,
14    Medium,
15    #[default]
16    Low,
17    Info,
18}
19
20impl SeverityFilter {
21    /// Convert to the corresponding [`crate::finding::Severity`] threshold.
22    pub fn to_severity(self) -> crate::finding::Severity {
23        use crate::finding::Severity;
24        match self {
25            SeverityFilter::Critical => Severity::Critical,
26            SeverityFilter::High => Severity::High,
27            SeverityFilter::Medium => Severity::Medium,
28            SeverityFilter::Low => Severity::Low,
29            SeverityFilter::Info => Severity::Info,
30        }
31    }
32}
33
34// ── Category filter ───────────────────────────────────────────────────────────
35
36/// Which category to scan (default: all).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
38pub enum CategoryFilter {
39    Config,
40    Secrets,
41    Permissions,
42    Network,
43    Deps,
44    Hooks,
45    History,
46}
47
48impl CategoryFilter {
49    /// Convert to the corresponding [`crate::finding::Category`].
50    pub fn to_category(self) -> crate::finding::Category {
51        use crate::finding::Category;
52        match self {
53            CategoryFilter::Config => Category::ConfigSecurity,
54            CategoryFilter::Secrets => Category::SecretDetection,
55            CategoryFilter::Permissions => Category::FilePermissions,
56            CategoryFilter::Network => Category::NetworkSecurity,
57            CategoryFilter::Deps => Category::DependencySecurity,
58            CategoryFilter::Hooks => Category::HookSecurity,
59            CategoryFilter::History => Category::DataExposure,
60        }
61    }
62}
63
64// ── Main CLI struct ───────────────────────────────────────────────────────────
65
66/// Security scanner for agentic AI framework installations.
67///
68/// Scans OpenClaw, Claude Code, and any compatible agentic framework directory
69/// for security misconfigurations, exposed credentials, permission issues, and
70/// supply-chain risks. Works with all model backends (Claude, OpenAI, Mistral,
71/// xAI, OpenRouter, and more).
72///
73/// Auto-detects ~/.claude, ~/.openclaw, ~/.config/openclaw, or $OPENCLAW_HOME
74/// if no path is given.
75#[derive(Parser, Debug)]
76#[command(
77    name = "ocls",
78    version,
79    author,
80    about = "Security scanner for agentic AI framework installations",
81    long_about = None,
82)]
83pub struct Cli {
84    /// Path(s) to scan. Repeatable.
85    ///
86    /// If omitted, auto-detects from $OPENCLAW_HOME, ~/.openclaw,
87    /// ~/.claude, or ~/.config/openclaw (in that priority order).
88    #[arg(value_name = "PATH")]
89    pub paths: Vec<PathBuf>,
90
91    /// Output machine-readable JSON instead of the rich terminal view.
92    #[arg(short = 'j', long)]
93    pub json: bool,
94
95    /// Suppress the banner; emit findings only.
96    #[arg(short = 'q', long)]
97    pub quiet: bool,
98
99    /// Show remediation steps and evidence for every finding.
100    #[arg(short = 'v', long)]
101    pub verbose: bool,
102
103    /// Disable ANSI colour codes.
104    ///
105    /// Colours are also automatically disabled when stdout is not a tty.
106    #[arg(long)]
107    pub no_color: bool,
108
109    /// Scan only the specified category.
110    #[arg(long, value_name = "CATEGORY")]
111    pub category: Option<CategoryFilter>,
112
113    /// Minimum severity level to include in the report.
114    #[arg(long, value_name = "SEVERITY", default_value = "low")]
115    pub min_severity: SeverityFilter,
116
117    /// Exclude paths matching this glob from scanning. Repeatable.
118    #[arg(long, value_name = "GLOB")]
119    pub ignore_path: Vec<String>,
120}
121
122// ── Tests ─────────────────────────────────────────────────────────────────────
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use clap::CommandFactory;
128
129    #[test]
130    fn cli_debug_assert() {
131        // Verifies there are no conflicting arg names, missing value parsers, etc.
132        Cli::command().debug_assert();
133    }
134
135    #[test]
136    fn severity_filter_default_is_low() {
137        let cli = Cli::parse_from(["ocls"]);
138        assert_eq!(cli.min_severity, SeverityFilter::Low);
139    }
140
141    #[test]
142    fn severity_filter_to_severity_roundtrip() {
143        use crate::finding::Severity;
144        assert_eq!(SeverityFilter::Critical.to_severity(), Severity::Critical);
145        assert_eq!(SeverityFilter::High.to_severity(), Severity::High);
146        assert_eq!(SeverityFilter::Medium.to_severity(), Severity::Medium);
147        assert_eq!(SeverityFilter::Low.to_severity(), Severity::Low);
148        assert_eq!(SeverityFilter::Info.to_severity(), Severity::Info);
149    }
150
151    #[test]
152    fn json_flag_parsed() {
153        let cli = Cli::parse_from(["ocls", "--json"]);
154        assert!(cli.json);
155    }
156
157    #[test]
158    fn verbose_flag_parsed() {
159        let cli = Cli::parse_from(["ocls", "-v"]);
160        assert!(cli.verbose);
161    }
162
163    #[test]
164    fn no_color_flag_parsed() {
165        let cli = Cli::parse_from(["ocls", "--no-color"]);
166        assert!(cli.no_color);
167    }
168
169    #[test]
170    fn multiple_paths_parsed() {
171        let cli = Cli::parse_from(["ocls", "/tmp/a", "/tmp/b"]);
172        assert_eq!(cli.paths.len(), 2);
173    }
174
175    #[test]
176    fn category_filter_to_category() {
177        use crate::finding::Category;
178        assert_eq!(
179            CategoryFilter::Secrets.to_category(),
180            Category::SecretDetection
181        );
182        assert_eq!(
183            CategoryFilter::Config.to_category(),
184            Category::ConfigSecurity
185        );
186        assert_eq!(
187            CategoryFilter::Permissions.to_category(),
188            Category::FilePermissions
189        );
190        assert_eq!(
191            CategoryFilter::Network.to_category(),
192            Category::NetworkSecurity
193        );
194        assert_eq!(
195            CategoryFilter::Deps.to_category(),
196            Category::DependencySecurity
197        );
198        assert_eq!(CategoryFilter::Hooks.to_category(), Category::HookSecurity);
199        assert_eq!(
200            CategoryFilter::History.to_category(),
201            Category::DataExposure
202        );
203    }
204
205    #[test]
206    fn min_severity_medium_parsed() {
207        let cli = Cli::parse_from(["ocls", "--min-severity", "medium"]);
208        assert_eq!(cli.min_severity, SeverityFilter::Medium);
209    }
210
211    #[test]
212    fn ignore_path_repeatable() {
213        let cli = Cli::parse_from(["ocls", "--ignore-path", "*.log", "--ignore-path", "tmp/"]);
214        assert_eq!(cli.ignore_path.len(), 2);
215    }
216}