diff --git a/.gitignore b/.gitignore index fa44e16..a8f8cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # AI -/.sisyphus # Rust builds /target diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d4fba..9f778ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed + +- Simplified distribution to a single shipped binary: `key-watch` +- Git hook installation now supports first-class global hooks via `core.hooksPath` +- Installation guidance is now cargo-first, with manual GitHub Releases setup documented step by step +- CLI moved from flat top-level flags to subcommands: `scan`, `hook install|uninstall`, `init`, and `verify-integrity` +- Local hook installation now resolves Git's hooks directory directly, improving worktree and submodule compatibility + +### Added + +- Hook uninstall support for local and global Git hooks +- `init bash|zsh|fish|posix` to print shell aliases for `keywatch` and `kw` +- README now documents uninstall steps for both `cargo install` and manual GitHub Releases installs +- Regression coverage for overlapping scan roots with root-relative exclude patterns + +### Removed + +- Duplicate Cargo binary wrappers for `keywatch` and `watch` +- `scripts/install.sh` in favor of documented `cargo install` and manual release-binary setup + ## [1.1.0] - 2026-05-05 ### Added diff --git a/Cargo.toml b/Cargo.toml index fb2ffcb..36d1c31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,19 +10,6 @@ homepage = "https://github.com/pixincreate/KeyWatch" keywords = ["secret-scanner", "security", "credentials", "lint"] license = "GPL-3.0-only" -license-file = "LICENSE" - -[[bin]] -name = "key-watch" -path = "src/main.rs" - -[[bin]] -name = "keywatch" -path = "src/bin/keywatch.rs" - -[[bin]] -name = "watch" -path = "src/bin/watch.rs" [dependencies] clap = { version = "4.6.1", features = ["derive"] } diff --git a/README.md b/README.md index 1569353..5d21209 100644 --- a/README.md +++ b/README.md @@ -4,65 +4,163 @@ A fast secret scanner for files and directories. ## Install +### Recommended: cargo install + ```sh -# Recommended -cargo install --git https://github.com/pixincreate/KeyWatch.git +cargo install key-watch +key-watch --version + +# Enable aliases for your current shell session +eval "$(key-watch init bash)" +``` -# Or use the install script -./scripts/install.sh +To make aliases persistent, add the init line to your shell config file: + +```sh +# bash +echo 'eval "$(key-watch init bash)"' >> ~/.bashrc -# Manual: download binary, add to PATH +# zsh +echo 'eval "$(key-watch init zsh)"' >> ~/.zshrc +``` + +### Manual install from GitHub Releases + +1. Download the correct binary for your OS/architecture from GitHub Releases. +2. Move it to a directory on your `PATH`, for example `~/.local/bin`. +3. Make it executable. +4. Verify it runs. +5. Enable aliases with `init`. + +```sh +mkdir -p ~/.local/bin +mv ~/Downloads/key-watch ~/.local/bin/key-watch +chmod +x ~/.local/bin/key-watch +~/.local/bin/key-watch --version + +# Enable aliases for current shell session +eval "$(~/.local/bin/key-watch init bash)" ``` Requires Rust 1.85+ (edition 2024) when building from source. +The canonical command is `key-watch`. +`keywatch` and `kw` are optional shell aliases exposed via `key-watch init ...`. + +## Uninstall + +### If installed with `cargo install` + +```sh +cargo uninstall key-watch +``` + +If you added aliases to your shell config, remove the init line you added earlier, for example: + +```sh +# bash +sed -i.bak '/key-watch init bash/d' ~/.bashrc + +# zsh +sed -i.bak '/key-watch init zsh/d' ~/.zshrc +``` + +### If installed manually from GitHub Releases + +1. Remove the `key-watch` binary from your `PATH` directory. +2. Remove any shell init line you added for aliases. +3. Restart your shell or reload your shell config. + +```sh +rm -f ~/.local/bin/key-watch + +# If you added aliases for the current shell config, remove that line manually +# then reload your shell config, for example: +source ~/.bashrc +``` + ## Usage ```sh # Scan a file -keywatch --file secrets.txt +key-watch scan secrets.txt # Scan a directory -keywatch --dir . +key-watch scan . # Verbose output (JSON) -keywatch --file secrets.txt --verbose +key-watch scan secrets.txt --verbose # Install git hook -keywatch --install-hook pre-commit -keywatch --install-hook pre-push +key-watch hook install pre-commit +key-watch hook install pre-push + +# Remove git hook +key-watch hook uninstall pre-commit +key-watch hook uninstall pre-push + +# Install git hook globally via core.hooksPath +key-watch hook install pre-commit --global +key-watch hook install pre-push --global + +# Remove global hook +key-watch hook uninstall pre-commit --global +key-watch hook uninstall pre-push --global + +# Print shell aliases +eval "$(key-watch init bash)" + +# Verify binary integrity +key-watch verify-integrity ``` ## Options -- `--file ` - Scan a single file -- `--dir ` - Scan a directory recursively -- `--output ` - Save report to file -- `--verbose` - Print full JSON output -- `--exclude ` - Comma-separated glob patterns to exclude -- `--exit-mode ` - Exit behavior: `always` (always pass), `critical` (fail on HIGH only), `strict` (fail on any finding, default) -- `--install-hook ` - Install pre-commit or pre-push hook -- `--verify-integrity` - Check binary hasn't been tampered with -- `--allowed-repos ` - Whitelist repos (pre-push) -- `--blocked-repos ` - Block repos (pre-push) +- `scan ...` - Scan one or more files or directories +- `scan --output ` - Save report to file +- `scan --verbose` - Print full JSON output +- `scan --exclude ` - Comma-separated glob patterns to exclude +- `scan --exit-mode ` - Exit behavior: `always` (always pass), `critical` (fail on HIGH only), `strict` (fail on any finding, default) +- `hook install [--global]` - Install a git hook +- `hook uninstall [--global]` - Remove a git hook +- `hook install pre-push --allowed-repos ` - Whitelist repos for pre-push hooks +- `hook install pre-push --blocked-repos ` - Block repos for pre-push hooks +- `hook install pre-commit --exclude ` - Exclude patterns for pre-commit scans +- `init ` - Print shell aliases for `keywatch` and `kw` +- `verify-integrity` - Check binary hasn't been tampered with ## Aliases -`key-watch`, `keywatch`, `watch` are equivalent. +- `key-watch` is the only shipped binary. +- `keywatch` and `kw` are optional aliases. +- `key-watch init bash|zsh|fish|posix` prints shell aliases you can eval in your shell. +- `watch` is intentionally not used, to avoid colliding with the standard Unix `watch` command. ## Exit Codes -| Code | Meaning | -| ---- | ------------------------------------------ | -| 0 | No secrets found (or `--exit-mode always`) | -| 1 | Secret found (in strict/critical mode) | -| 2 | Runtime/configuration error | +| Code | Meaning | +| ---- | ----------------------------------------------- | +| 0 | No secrets found (or `scan --exit-mode always`) | +| 1 | Secret found (in strict/critical mode) | +| 2 | Runtime/configuration error | ## Default Behavior - **Repos**: All allowed (no restrictions) - **Exit mode**: strict (fail on any finding) +## Git Hooks + +- `hook install pre-commit|pre-push` installs a repo-local hook into `.git/hooks/` +- `hook uninstall pre-commit|pre-push` removes a KeyWatch hook from the same target +- `hook install ... --global` installs into Git's global hooks directory +- `hook uninstall ... --global` removes the hook from Git's global hooks directory +- Local hook paths are resolved via `git rev-parse --git-path hooks`, so installs work in worktrees and submodules too +- If `core.hooksPath` is already configured, KeyWatch installs into that directory +- Otherwise KeyWatch creates a managed hooks directory and configures `git config --global core.hooksPath` +- KeyWatch refuses to overwrite a non-KeyWatch global hook file +- KeyWatch also refuses to remove a non-KeyWatch global hook file + ## Development ```sh diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 738d312..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/sh -# KeyWatch install/uninstall script - -BINARY_NAME="key-watch" -INSTALL_DIR="${HOME}/.local/bin" - -case "$1" in - uninstall|remove) - if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then - rm -f "${INSTALL_DIR}/${BINARY_NAME}" - echo "Removed ${BINARY_NAME} from ${INSTALL_DIR}" - fi - for alt in keywatch watch; do - if [ -L "${INSTALL_DIR}/${alt}" ]; then - rm -f "${INSTALL_DIR}/${alt}" - echo "Removed ${alt} alias" - fi - done - ;; - install|"") - if command -v cargo >/dev/null 2>&1; then - echo "Installing via cargo..." - cargo install --git https://github.com/pixincreate/KeyWatch.git || cargo install --path . - exit $? - fi - - echo "cargo not found. Looking for pre-built binary..." - - BIN_PATH="" - for path in "./target/release/${BINARY_NAME}" "./target/debug/${BINARY_NAME}"; do - if [ -f "$path" ]; then - BIN_PATH="$path" - break - fi - done - - if [ -z "$BIN_PATH" ] && [ -n "$2" ] && [ -f "$2" ]; then - BIN_PATH="$2" - fi - - if [ -z "$BIN_PATH" ]; then - echo "Binary not found. Build with 'cargo build --release' or provide path:" - echo " $0 install /path/to/key-watch" - exit 1 - fi - - mkdir -p "${INSTALL_DIR}" - cp "$BIN_PATH" "${INSTALL_DIR}/${BINARY_NAME}" - chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - - ln -sf "${INSTALL_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/keywatch" 2>/dev/null || true - ln -sf "${INSTALL_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/watch" 2>/dev/null || true - - echo "Installed to ${INSTALL_DIR}" - echo "Add ${INSTALL_DIR} to your PATH if not already present" - ;; - *) - echo "Usage: $0 [install|uninstall] [/path/to/binary]" - ;; -esac \ No newline at end of file diff --git a/src/bin/keywatch.rs b/src/bin/keywatch.rs deleted file mode 100644 index 984917f..0000000 --- a/src/bin/keywatch.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() { - if let Err(err) = key_watch::run_cli() { - eprintln!("Error: {}", err); - std::process::exit(key_watch::EXIT_CODE_RUNTIME_ERROR); - } -} diff --git a/src/bin/watch.rs b/src/bin/watch.rs deleted file mode 100644 index 984917f..0000000 --- a/src/bin/watch.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() { - if let Err(err) = key_watch::run_cli() { - eprintln!("Error: {}", err); - std::process::exit(key_watch::EXIT_CODE_RUNTIME_ERROR); - } -} diff --git a/src/cli.rs b/src/cli.rs index 703436d..8de7343 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,23 +1,45 @@ -use clap::{ArgGroup, Parser}; +use clap::{Args, Parser, Subcommand, ValueEnum}; /// KeyWatch: A secret scanner for your files and directories. #[derive(Parser, Debug)] #[command(author, version, about = "Scan files and directories for secrets", long_about = None)] -#[command(group( - ArgGroup::new("target") - .required(true) - .multiple(false) - .args(&["file", "dir", "install_hook"]), -))] pub struct CliOptions { - /// Scan specific file(s) - supports multiple --file flags - /// Example: --file file1.txt --file file2.txt - #[arg(short, long, alias = "files")] - pub file: Vec, + #[command(subcommand)] + pub command: Command, +} - /// Scan all files in a directory (scans recursively) - #[arg(short, long)] - pub dir: Option, +impl CliOptions { + pub fn validate(&self) -> Result<(), String> { + match &self.command { + Command::Hook(args) => args.validate(), + _ => Ok(()), + } + } +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Scan files or directories + Scan(ScanArgs), + + /// Manage KeyWatch git hooks + Hook(HookArgs), + + /// Print shell aliases for keywatch and kw + Init { + #[arg(value_enum)] + shell: Shell, + }, + + /// Verify binary integrity on startup + VerifyIntegrity, +} + +#[derive(Args, Debug)] +pub struct ScanArgs { + /// Paths to scan (files or directories) + #[arg(required = true)] + pub paths: Vec, /// Output the result to a file #[arg(short, long)] @@ -27,34 +49,142 @@ pub struct CliOptions { #[arg(short, long, default_value_t = false)] pub verbose: bool, - /// Allowed repository URLs (comma-separated) - /// Experimental: Push to these repos will be allowed + /// Paths to exclude from scanning (comma-separated, supports glob patterns) + #[arg(long)] + pub exclude: Option, + + /// Exit code behavior + #[arg(long, value_enum, default_value_t = ExitMode::Strict)] + pub exit_mode: ExitMode, +} + +#[derive(Args, Debug)] +pub struct HookArgs { + #[command(subcommand)] + pub action: HookAction, +} + +impl HookArgs { + fn validate(&self) -> Result<(), String> { + match &self.action { + HookAction::Install(args) => args.validate(), + HookAction::Uninstall(_) => Ok(()), + } + } +} + +#[derive(Subcommand, Debug)] +pub enum HookAction { + /// Install a KeyWatch git hook + Install(HookInstallArgs), + + /// Remove a KeyWatch git hook + Uninstall(HookUninstallArgs), +} + +#[derive(Args, Debug)] +pub struct HookInstallArgs { + /// Type of hook to install + #[arg(value_enum)] + pub hook_type: HookType, + + /// Install the hook globally using git core.hooksPath + #[arg(long, default_value_t = false)] + pub global: bool, + + /// Allowed repository URLs (comma-separated) - pre-push only #[arg(long)] pub allowed_repos: Option, - /// Blocked repository URLs (comma-separated) - /// Experimental: Push to these repos will be blocked + /// Blocked repository URLs (comma-separated) - pre-push only #[arg(long)] pub blocked_repos: Option, - /// Paths to exclude from scanning (comma-separated, supports glob patterns) + /// Paths to exclude from scanning - pre-commit only #[arg(long)] pub exclude: Option, +} - /// Install KeyWatch as a git hook - /// Options: pre-push, pre-commit - #[arg(long, value_parser = ["pre-push", "pre-commit"])] - pub install_hook: Option, +impl HookInstallArgs { + fn validate(&self) -> Result<(), String> { + match self.hook_type { + HookType::PreCommit => { + if self.allowed_repos.is_some() || self.blocked_repos.is_some() { + return Err( + "--allowed-repos and --blocked-repos are only supported for pre-push hooks" + .to_string(), + ); + } + } + HookType::PrePush => { + if self.exclude.is_some() { + return Err("--exclude is only supported for pre-commit hooks".to_string()); + } + } + } - /// Exit code behavior - /// Options: - /// - always: Always exit 0 (bypass) - /// - critical: Exit 0 if only LOW/MEDIUM severity - /// - strict: Exit non-zero for any finding (default) - #[arg(long, default_value = "strict")] - pub exit_mode: String, + Ok(()) + } +} - /// Verify binary integrity on startup +#[derive(Args, Debug)] +pub struct HookUninstallArgs { + /// Type of hook to remove + #[arg(value_enum)] + pub hook_type: HookType, + + /// Remove the hook globally from git core.hooksPath #[arg(long, default_value_t = false)] - pub verify_integrity: bool, + pub global: bool, +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum HookType { + PreCommit, + PrePush, +} + +impl HookType { + pub fn as_str(&self) -> &'static str { + match self { + Self::PreCommit => "pre-commit", + Self::PrePush => "pre-push", + } + } +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum Shell { + Bash, + Zsh, + Fish, + Posix, +} + +impl Shell { + pub fn as_str(&self) -> &'static str { + match self { + Self::Bash => "bash", + Self::Zsh => "zsh", + Self::Fish => "fish", + Self::Posix => "posix", + } + } +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum ExitMode { + Always, + Critical, + Strict, +} + +impl ExitMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Always => "always", + Self::Critical => "critical", + Self::Strict => "strict", + } + } } diff --git a/src/hooks.rs b/src/hooks.rs index 6064b37..72fafca 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,4 +1,4 @@ -use crate::cli::CliOptions; +use crate::cli::HookInstallArgs; const PRE_PUSH_TEMPLATE: &str = include_str!("../templates/pre-push.sh"); const PRE_COMMIT_TEMPLATE: &str = include_str!("../templates/pre-commit.sh"); @@ -26,21 +26,19 @@ fn build_repo_section(allowed: Option<&str>, blocked: Option<&str>) -> String { format!("{}{}", allowed_line, blocked_line) } -fn render_pre_push(options: &CliOptions) -> String { +fn render_pre_push(args: &HookInstallArgs) -> String { let binary_name = shell_escape(&hook_binary_name()); - let repo_section = build_repo_section( - options.allowed_repos.as_deref(), - options.blocked_repos.as_deref(), - ); + let repo_section = + build_repo_section(args.allowed_repos.as_deref(), args.blocked_repos.as_deref()); PRE_PUSH_TEMPLATE .replace("{{binary_name}}", &binary_name) .replace("{{repo_section}}", &repo_section) } -fn render_pre_commit(options: &CliOptions) -> String { +fn render_pre_commit(args: &HookInstallArgs) -> String { let binary_name = shell_escape(&hook_binary_name()); - let exclude_patterns = options + let exclude_patterns = args .exclude .as_deref() .map(shell_escape) @@ -51,12 +49,12 @@ fn render_pre_commit(options: &CliOptions) -> String { .replace("{{exclude_patterns}}", &exclude_patterns) } -pub fn generate_pre_push_hook(options: &CliOptions) -> String { - render_pre_push(options) +pub fn generate_pre_push_hook(args: &HookInstallArgs) -> String { + render_pre_push(args) } -pub fn generate_pre_commit_hook(options: &CliOptions) -> String { - render_pre_commit(options) +pub fn generate_pre_commit_hook(args: &HookInstallArgs) -> String { + render_pre_commit(args) } fn hook_binary_name() -> String { diff --git a/src/lib.rs b/src/lib.rs index 9c5887c..5740200 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,33 +6,44 @@ pub mod scanner; pub mod utils; use clap::Parser; -use cli::CliOptions; +use cli::{ + CliOptions, Command, ExitMode, HookAction, HookInstallArgs, HookType, HookUninstallArgs, + ScanArgs, Shell, +}; use report::Finding; use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; use std::time::Instant; pub use hooks::{generate_pre_commit_hook, generate_pre_push_hook}; -const EXIT_MODE_ALWAYS: &str = "always"; -const EXIT_MODE_CRITICAL: &str = "critical"; -const EXIT_MODE_STRICT: &str = "strict"; const SEVERITY_HIGH: &str = "HIGH"; pub const EXIT_CODE_RUNTIME_ERROR: i32 = 2; pub fn run_cli() -> Result<(), String> { let options = CliOptions::parse(); - let start = Instant::now(); + options.validate()?; - if let Some(hook_type) = &options.install_hook { - install_hook(hook_type, &options)?; - return Ok(()); + match options.command { + Command::Scan(args) => run_scan_command(&args), + Command::Hook(args) => match args.action { + HookAction::Install(install_args) => install_hook(&install_args), + HookAction::Uninstall(uninstall_args) => uninstall_hook(&uninstall_args), + }, + Command::Init { shell } => { + print_shell_init(&shell); + Ok(()) + } + Command::VerifyIntegrity => verify_binary_integrity(), } +} - if options.verify_integrity { - verify_binary_integrity()?; - } +fn run_scan_command(args: &ScanArgs) -> Result<(), String> { + let start = Instant::now(); - let (findings, scan_metadata) = scanner::run_scan(&options)?; + let (findings, scan_metadata) = scanner::run_scan(args)?; let elapsed = start.elapsed(); let scan_time = format!( "{}.{:01}s", @@ -40,12 +51,12 @@ pub fn run_cli() -> Result<(), String> { elapsed.subsec_millis() / 100 ); let severity_counts = report::get_severity_counts(&findings); - let exit_code = calculate_exit_code(&findings, &options.exit_mode); + let exit_code = calculate_exit_code(&findings, &args.exit_mode); let findings_count = findings.len(); let report_json = report::create_report(findings, scan_metadata, scan_time) .map_err(|err| format!("Failed to serialize report: {}", err))?; - if options.verbose { + if args.verbose { println!("{report_json}"); } else if findings_count == 0 { println!("No secrets found."); @@ -56,7 +67,7 @@ pub fn run_cli() -> Result<(), String> { ); } - if let Some(ref output_path) = options.output { + if let Some(ref output_path) = args.output { utils::write_to_file(output_path, &report_json) .map_err(|err| format!("Failed to write report to '{}': {}", output_path, err))?; } @@ -64,29 +75,358 @@ pub fn run_cli() -> Result<(), String> { std::process::exit(exit_code); } -fn install_hook(hook_type: &str, options: &CliOptions) -> Result<(), String> { - let hook_content = match hook_type { - "pre-push" => hooks::generate_pre_push_hook(options), - "pre-commit" => hooks::generate_pre_commit_hook(options), - _ => { - return Err(format!("Unknown hook type: {}", hook_type)); - } +fn install_hook(args: &HookInstallArgs) -> Result<(), String> { + let hook_content = match args.hook_type { + HookType::PrePush => hooks::generate_pre_push_hook(args), + HookType::PreCommit => hooks::generate_pre_commit_hook(args), }; - let hook_path = format!(".git/hooks/{hook_type}"); + let hook_type_str = args.hook_type.as_str(); + let install_target = resolve_hook_install_target(hook_type_str, args.global)?; + + if !install_target.is_global { + ensure_local_hook_target_is_safe_to_create(&install_target.path)?; + } + + if let Some(parent) = install_target.path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "Failed to create hook directory '{}': {}", + parent.display(), + err + ) + })?; + } + + if install_target.is_global { + ensure_global_hook_target_is_safe(&install_target.path)?; + } + + let hook_path = install_target.path.to_string_lossy().into_owned(); utils::write_to_file(&hook_path, &hook_content) - .map_err(|err| format!("Failed to install hook '{}': {}", hook_type, err))?; + .map_err(|err| format!("Failed to install hook '{}': {}", hook_type_str, err))?; utils::make_executable(&hook_path) .map_err(|err| format!("Failed to make hook executable '{}': {}", hook_path, err))?; - println!("Installed {hook_type} hook at {hook_path}"); + if install_target.configured_global_path { + println!( + "Configured git --global core.hooksPath to {}", + install_target.hooks_dir.display() + ); + } + + if install_target.is_global { + println!("Installed global {hook_type_str} hook at {hook_path}"); + } else { + println!("Installed {hook_type_str} hook at {hook_path}"); + } println!( "The hook will run automatically during git {}.", - hook_type.replace('-', " ") + hook_type_str.replace('-', " ") ); Ok(()) } +fn uninstall_hook(args: &HookUninstallArgs) -> Result<(), String> { + let hook_type_str = args.hook_type.as_str(); + let install_target = resolve_hook_uninstall_target(hook_type_str, args.global)?; + + if !install_target.path.exists() { + let scope = if install_target.is_global { + "global" + } else { + "local" + }; + println!( + "No {scope} {hook_type_str} hook found at {}", + install_target.path.display() + ); + return Ok(()); + } + + ensure_hook_target_is_keywatch_managed( + &install_target.path, + install_target.is_global, + "remove", + )?; + + fs::remove_file(&install_target.path).map_err(|err| { + format!( + "Failed to remove hook '{}': {}", + install_target.path.display(), + err + ) + })?; + + if install_target.is_global { + println!( + "Removed global {hook_type_str} hook at {}", + install_target.path.display() + ); + } else { + println!( + "Removed {hook_type_str} hook at {}", + install_target.path.display() + ); + } + + Ok(()) +} + +fn print_shell_init(shell: &Shell) { + let script = match shell { + Shell::Fish => "alias keywatch 'key-watch'\nalias kw 'key-watch'\n", + Shell::Bash | Shell::Zsh | Shell::Posix => { + "alias keywatch='key-watch'\nalias kw='key-watch'\n" + } + }; + + print!("{script}"); +} + +struct HookInstallTarget { + path: PathBuf, + hooks_dir: PathBuf, + is_global: bool, + configured_global_path: bool, +} + +impl HookInstallTarget { + fn local(hook_type: &str) -> Result { + let hooks_dir = resolve_local_hooks_dir()?; + Ok(Self { + path: hooks_dir.join(hook_type), + hooks_dir, + is_global: false, + configured_global_path: false, + }) + } + + fn global(hook_type: &str) -> Result { + let configured = read_global_hooks_path()?; + let hooks_dir = match configured { + Some(path) => path, + None => { + let managed_dir = managed_global_hooks_dir( + env::var_os("XDG_CONFIG_HOME"), + env::var_os("HOME"), + env::var_os("APPDATA"), + env::var_os("USERPROFILE"), + )?; + fs::create_dir_all(&managed_dir).map_err(|err| { + format!( + "Failed to create global hooks directory '{}': {}", + managed_dir.display(), + err + ) + })?; + configure_global_hooks_path(&managed_dir)?; + return Ok(Self { + path: managed_dir.join(hook_type), + hooks_dir: managed_dir, + is_global: true, + configured_global_path: true, + }); + } + }; + + Ok(Self { + path: hooks_dir.join(hook_type), + hooks_dir, + is_global: true, + configured_global_path: false, + }) + } +} + +fn resolve_hook_install_target(hook_type: &str, global: bool) -> Result { + if global { + HookInstallTarget::global(hook_type) + } else { + HookInstallTarget::local(hook_type) + } +} + +fn resolve_hook_uninstall_target( + hook_type: &str, + global: bool, +) -> Result { + if global { + let hooks_dir = match read_global_hooks_path()? { + Some(path) => path, + None => managed_global_hooks_dir( + env::var_os("XDG_CONFIG_HOME"), + env::var_os("HOME"), + env::var_os("APPDATA"), + env::var_os("USERPROFILE"), + )?, + }; + + Ok(HookInstallTarget { + path: hooks_dir.join(hook_type), + hooks_dir, + is_global: true, + configured_global_path: false, + }) + } else { + HookInstallTarget::local(hook_type) + } +} + +fn resolve_local_hooks_dir() -> Result { + resolve_local_hooks_dir_from(Path::new(".")) +} + +fn resolve_local_hooks_dir_from(cwd: &Path) -> Result { + let output = ProcessCommand::new("git") + .current_dir(cwd) + .args(["rev-parse", "--path-format=absolute", "--git-path", "hooks"]) + .output() + .map_err(|err| format!("Failed to resolve git hooks directory: {}", err))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + "Local hook installation requires running inside a git repository".to_string() + } else { + format!("Local hook installation requires running inside a git repository: {stderr}") + }); + } + + let hooks_path = String::from_utf8(output.stdout) + .map_err(|err| format!("Invalid UTF-8 from git rev-parse output: {}", err))? + .trim() + .to_string(); + + if hooks_path.is_empty() { + return Err("Failed to resolve git hooks directory".to_string()); + } + + Ok(PathBuf::from(hooks_path)) +} + +fn read_global_hooks_path() -> Result, String> { + let output = ProcessCommand::new("git") + .args(["config", "--global", "--path", "--get", "core.hooksPath"]) + .output() + .map_err(|err| format!("Failed to read git global core.hooksPath: {}", err))?; + + if output.status.success() { + let hooks_path = String::from_utf8(output.stdout) + .map_err(|err| format!("Invalid UTF-8 from git config output: {}", err))? + .trim() + .to_string(); + + if hooks_path.is_empty() { + Ok(None) + } else { + Ok(Some(PathBuf::from(hooks_path))) + } + } else if output.status.code() == Some(1) { + Ok(None) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +fn managed_global_hooks_dir( + xdg_config_home: Option, + home: Option, + appdata: Option, + userprofile: Option, +) -> Result { + if let Some(xdg_config_home) = xdg_config_home { + return Ok(PathBuf::from(xdg_config_home) + .join("key-watch") + .join("hooks")); + } + + if let Some(home) = home { + return Ok(PathBuf::from(home) + .join(".config") + .join("key-watch") + .join("hooks")); + } + + if let Some(appdata) = appdata { + return Ok(PathBuf::from(appdata).join("key-watch").join("hooks")); + } + + if let Some(userprofile) = userprofile { + return Ok(PathBuf::from(userprofile) + .join(".config") + .join("key-watch") + .join("hooks")); + } + + Err("Could not determine a directory for global git hooks".to_string()) +} + +fn configure_global_hooks_path(hooks_dir: &Path) -> Result<(), String> { + let output = ProcessCommand::new("git") + .args([ + "config", + "--global", + "core.hooksPath", + hooks_dir.to_string_lossy().as_ref(), + ]) + .output() + .map_err(|err| format!("Failed to configure git global core.hooksPath: {}", err))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(format!( + "git config --global core.hooksPath {} failed: {}", + hooks_dir.display(), + stderr + )) + } +} + +fn ensure_global_hook_target_is_safe(hook_path: &Path) -> Result<(), String> { + ensure_hook_target_is_keywatch_managed(hook_path, true, "overwrite") +} + +fn ensure_local_hook_target_is_safe_to_create(hook_path: &Path) -> Result<(), String> { + resolve_local_hooks_dir()?; + + if hook_path.exists() { + ensure_hook_target_is_keywatch_managed(hook_path, false, "overwrite")?; + } + + Ok(()) +} + +fn ensure_hook_target_is_keywatch_managed( + hook_path: &Path, + is_global: bool, + action: &str, +) -> Result<(), String> { + if !hook_path.exists() { + return Ok(()); + } + + let existing_hook = fs::read_to_string(hook_path).map_err(|err| { + format!( + "Failed to inspect existing hook '{}': {}", + hook_path.display(), + err + ) + })?; + + if existing_hook.contains("# Installed by KeyWatch") { + return Ok(()); + } + + let scope = if is_global { "global" } else { "local" }; + Err(format!( + "Refusing to {action} existing {scope} hook at '{}'. Merge it manually or remove it yourself.", + hook_path.display() + )) +} + fn verify_binary_integrity() -> Result<(), String> { let exe_path = env::current_exe().map_err(|err| format!("Failed to get executable path: {}", err))?; @@ -109,20 +449,167 @@ fn verify_binary_integrity() -> Result<(), String> { Ok(()) } -fn calculate_exit_code(findings: &[Finding], exit_mode: &str) -> i32 { +fn calculate_exit_code(findings: &[Finding], exit_mode: &ExitMode) -> i32 { if findings.is_empty() { return 0; } match exit_mode { - EXIT_MODE_ALWAYS => 0, - EXIT_MODE_CRITICAL => { + ExitMode::Always => 0, + ExitMode::Critical => { let has_high = findings .iter() .any(|finding| finding.severity == SEVERITY_HIGH); if has_high { 1 } else { 0 } } - EXIT_MODE_STRICT => 1, - _ => 1, + ExitMode::Strict => 1, + } +} + +#[cfg(test)] +mod tests { + use super::{ + ensure_global_hook_target_is_safe, ensure_local_hook_target_is_safe_to_create, + managed_global_hooks_dir, resolve_hook_uninstall_target, resolve_local_hooks_dir_from, + }; + use std::env; + use std::fs; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(name: &str) -> std::path::PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time should be after Unix epoch") + .as_millis(); + std::env::temp_dir().join(format!("keywatch_{name}_{timestamp}")) + } + + fn init_git_repo(path: &std::path::Path) { + fs::create_dir_all(path).expect("create repo dir"); + let status = Command::new("git") + .args(["init", "--quiet"]) + .current_dir(path) + .status() + .expect("run git init"); + assert!(status.success(), "git init should succeed"); + } + + #[test] + fn test_managed_global_hooks_dir_prefers_xdg() { + let path = managed_global_hooks_dir( + Some("/tmp/xdg".into()), + Some("/tmp/home".into()), + None, + None, + ) + .expect("xdg path should resolve"); + + assert_eq!(path, std::path::PathBuf::from("/tmp/xdg/key-watch/hooks")); + } + + #[test] + fn test_managed_global_hooks_dir_falls_back_to_home() { + let path = managed_global_hooks_dir(None, Some("/tmp/home".into()), None, None) + .expect("home path should resolve"); + + assert_eq!( + path, + std::path::PathBuf::from("/tmp/home/.config/key-watch/hooks") + ); + } + + #[test] + fn test_global_hook_safety_allows_keywatch_hook() { + let temp_dir = unique_temp_dir("global_hook_safe"); + fs::create_dir_all(&temp_dir).expect("create temp dir"); + let hook_path = temp_dir.join("pre-commit"); + + fs::write(&hook_path, "#!/bin/bash\n# Installed by KeyWatch\n").expect("write hook file"); + + ensure_global_hook_target_is_safe(&hook_path).expect("keywatch hook should be reusable"); + + fs::remove_file(&hook_path).expect("remove hook file"); + fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn test_global_hook_safety_rejects_foreign_hook() { + let temp_dir = unique_temp_dir("global_hook_foreign"); + fs::create_dir_all(&temp_dir).expect("create temp dir"); + let hook_path = temp_dir.join("pre-commit"); + + fs::write(&hook_path, "#!/bin/bash\necho custom hook\n").expect("write hook file"); + + let error = ensure_global_hook_target_is_safe(&hook_path) + .expect_err("foreign hook should be rejected"); + assert!(error.contains("Refusing to overwrite existing global hook")); + + fs::remove_file(&hook_path).expect("remove hook file"); + fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn test_resolve_local_hooks_dir_resolves_correctly() { + let temp_dir = unique_temp_dir("local_hooks_dir_resolution"); + init_git_repo(&temp_dir); + + let resolved = + resolve_local_hooks_dir_from(&temp_dir).expect("should resolve hooks dir inside repo"); + + assert!(resolved.is_absolute(), "hooks path should be absolute"); + assert!( + resolved.ends_with(".git/hooks"), + "hooks path should end with .git/hooks, but was: {}", + resolved.display() + ); + + fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn test_git_init_creates_hooks_dir() { + let temp_dir = unique_temp_dir("local_hook_missing_git"); + init_git_repo(&temp_dir); + + assert!(temp_dir.join(".git/hooks").exists()); + + fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn test_local_hook_safety_rejects_foreign_hook() { + let temp_dir = unique_temp_dir("local_hook_foreign"); + init_git_repo(&temp_dir); + let git_dir = temp_dir.join(".git"); + let git_hooks_dir = git_dir.join("hooks"); + fs::create_dir_all(&git_hooks_dir).expect("create hooks dir"); + let hook_path = git_hooks_dir.join("pre-commit"); + fs::write(&hook_path, "#!/bin/bash\necho custom hook\n").expect("write hook file"); + + let error = ensure_local_hook_target_is_safe_to_create(&hook_path) + .expect_err("foreign local hook should be rejected"); + assert!(error.contains("Refusing to overwrite existing local hook")); + + fs::remove_file(&hook_path).expect("remove hook file"); + fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn test_global_uninstall_target_does_not_configure_missing_hooks_path() { + let install_target = resolve_hook_uninstall_target("pre-commit", true) + .expect("global uninstall target should resolve"); + let expected_hooks_dir = managed_global_hooks_dir( + env::var_os("XDG_CONFIG_HOME"), + env::var_os("HOME"), + env::var_os("APPDATA"), + env::var_os("USERPROFILE"), + ) + .expect("managed hooks dir should resolve"); + + assert!(install_target.is_global); + assert!(!install_target.configured_global_path); + assert_eq!(install_target.hooks_dir, expected_hooks_dir); + assert_eq!(install_target.path, expected_hooks_dir.join("pre-commit")); } } diff --git a/src/scanner.rs b/src/scanner.rs index 51d3d01..f8a5d20 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -1,11 +1,11 @@ -use crate::cli::CliOptions; +use crate::cli::ScanArgs; use crate::detector::initialize_detectors; use crate::report::{Finding, ScanMetadata}; use glob::Pattern; use std::fs; use std::path::Path; -pub fn run_scan(options: &CliOptions) -> Result<(Vec, ScanMetadata), String> { +pub fn run_scan(args: &ScanArgs) -> Result<(Vec, ScanMetadata), String> { let mut findings = Vec::new(); let mut files_scanned = 0; let mut total_lines = 0; @@ -13,13 +13,31 @@ pub fn run_scan(options: &CliOptions) -> Result<(Vec, ScanMetadata), St let mut target_paths = Vec::new(); - if !options.file.is_empty() { - let mut unique_paths: Vec = options.file.to_vec(); - unique_paths.sort(); - unique_paths.dedup(); - target_paths.extend(unique_paths); - } else if let Some(ref dir_path) = options.dir { - collect_files(dir_path, &mut target_paths); + for path_str in &args.paths { + let path = Path::new(path_str); + if path.is_file() { + target_paths.push((path_str.clone(), None)); + } else if path.is_dir() { + collect_files(path_str, &mut target_paths, path_str); + } else { + // Push anyway, let read handle it or ignore + target_paths.push((path_str.clone(), None)); + } + } + + target_paths.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut unique_paths: Vec<(String, Vec>)> = Vec::new(); + for (path, root) in target_paths { + if let Some(last) = unique_paths.last_mut() { + if last.0 == path { + if !last.1.contains(&root) { + last.1.push(root); + } + continue; + } + } + unique_paths.push((path, vec![root])); } let detectors = initialize_detectors().map_err(|err| err.to_string())?; @@ -27,7 +45,7 @@ pub fn run_scan(options: &CliOptions) -> Result<(Vec, ScanMetadata), St .iter() .partition(|detector| detector.regex.as_str().contains("(?s)")); - let exclude_patterns: Vec = options + let exclude_patterns: Vec = args .exclude .as_ref() .map(|exclude_str| { @@ -43,14 +61,13 @@ pub fn run_scan(options: &CliOptions) -> Result<(Vec, ScanMetadata), St .transpose()? .unwrap_or_default(); - for path in target_paths { + for (path, roots) in unique_paths { if path_has_git_dir(Path::new(&path)) { excluded_files.push(path); continue; } - let should_exclude = - matches_exclude_patterns(&path, options.dir.as_deref(), &exclude_patterns); + let should_exclude = matches_exclude_patterns(&path, &roots, &exclude_patterns); if should_exclude { excluded_files.push(path); @@ -107,19 +124,19 @@ pub fn run_scan(options: &CliOptions) -> Result<(Vec, ScanMetadata), St Ok((findings, metadata)) } -fn collect_files(dir_path: &str, files: &mut Vec) { +fn collect_files(dir_path: &str, files: &mut Vec<(String, Option)>, root: &str) { if let Ok(entries) = fs::read_dir(dir_path) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { if let Some(path_str) = path.to_str() { - files.push(path_str.to_string()); + files.push((path_str.to_string(), Some(root.to_string()))); } } else if path.is_dir() && path.file_name().is_none_or(|name| name != ".git") && let Some(path_str) = path.to_str() { - collect_files(path_str, files); + collect_files(path_str, files, root); } } } @@ -130,7 +147,11 @@ fn path_has_git_dir(path: &Path) -> bool { .any(|component| component.as_os_str() == ".git") } -fn matches_exclude_patterns(path: &str, scan_root: Option<&str>, patterns: &[Pattern]) -> bool { +fn matches_exclude_patterns( + path: &str, + scan_roots: &[Option], + patterns: &[Pattern], +) -> bool { let path = Path::new(path); patterns.iter().any(|pattern| { @@ -139,8 +160,11 @@ fn matches_exclude_patterns(path: &str, scan_root: Option<&str>, patterns: &[Pat .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| pattern.matches(name)) - || scan_root - .and_then(|root| path.strip_prefix(root).ok()) - .is_some_and(|relative| pattern.matches_path(relative)) + || scan_roots.iter().any(|root_opt| { + root_opt + .as_deref() + .and_then(|root| path.strip_prefix(root).ok()) + .is_some_and(|relative| pattern.matches_path(relative)) + }) }) } diff --git a/templates/pre-commit.sh b/templates/pre-commit.sh index 0bcdd6a..460d1ac 100644 --- a/templates/pre-commit.sh +++ b/templates/pre-commit.sh @@ -17,13 +17,13 @@ while IFS= read -r -d '' file; do if [ ! -f "$file" ]; then continue fi - if "$KEYWATCH_BIN" --file "$file" --exclude "$EXCLUDE_PATTERNS" 2>/dev/null; then + if "$KEYWATCH_BIN" scan "$file" --exclude "$EXCLUDE_PATTERNS" 2>/dev/null; then continue fi EXIT_CODE=$? if [ $EXIT_CODE -eq 1 ]; then echo "ERROR: Secret detected in $file" - "$KEYWATCH_BIN" --file "$file" --exclude "$EXCLUDE_PATTERNS" --verbose + "$KEYWATCH_BIN" scan "$file" --exclude "$EXCLUDE_PATTERNS" --verbose exit 1 fi echo "Error: key-watch failed on $file (exit code: $EXIT_CODE)" >&2 diff --git a/templates/pre-push.sh b/templates/pre-push.sh index ee8c3c5..9eb3ce0 100644 --- a/templates/pre-push.sh +++ b/templates/pre-push.sh @@ -37,5 +37,5 @@ if [ -n "$CURRENT_REMOTE" ] && [ -n "${BLOCKED_REPOS:-}" ]; then done fi -"$KEYWATCH_BIN" --dir . --exit-mode critical +"$KEYWATCH_BIN" scan . --exit-mode critical exit $? diff --git a/tests/exit_tests.rs b/tests/exit_tests.rs index e8bfd0f..6fa5bca 100644 --- a/tests/exit_tests.rs +++ b/tests/exit_tests.rs @@ -33,7 +33,7 @@ fn test_exit_code_on_secrets() { let status = Command::new(env!("CARGO_BIN_EXE_key-watch")) .current_dir(&test_dir) - .arg("--file") + .arg("scan") .arg(&temp_file) .status() .expect("Run key-watch"); @@ -55,7 +55,7 @@ fn test_exit_code_on_no_secrets() { let status = Command::new(env!("CARGO_BIN_EXE_key-watch")) .current_dir(&test_dir) - .arg("--file") + .arg("scan") .arg(&temp_file) .status() .expect("Run key-watch"); @@ -77,7 +77,7 @@ fn test_runtime_errors_exit_with_code_two() { let status = Command::new(env!("CARGO_BIN_EXE_key-watch")) .current_dir(&test_dir) - .arg("--file") + .arg("scan") .arg(&temp_file) .status() .expect("Run key-watch"); @@ -95,7 +95,7 @@ fn test_exit_mode_always() { let status = Command::new(env!("CARGO_BIN_EXE_key-watch")) .current_dir(&test_dir) - .arg("--file") + .arg("scan") .arg(&temp_file) .arg("--exit-mode") .arg("always") @@ -115,7 +115,7 @@ fn test_exit_mode_critical_high_vs_low() { let high_status = Command::new(env!("CARGO_BIN_EXE_key-watch")) .current_dir(&high_dir) - .arg("--file") + .arg("scan") .arg(&high_file) .arg("--exit-mode") .arg("critical") @@ -134,7 +134,7 @@ fn test_exit_mode_critical_high_vs_low() { let low_status = Command::new(env!("CARGO_BIN_EXE_key-watch")) .current_dir(&low_dir) - .arg("--file") + .arg("scan") .arg(&low_file) .arg("--exit-mode") .arg("critical") diff --git a/tests/hooks_tests.rs b/tests/hooks_tests.rs index c11c977..c32ccb0 100644 --- a/tests/hooks_tests.rs +++ b/tests/hooks_tests.rs @@ -1,20 +1,24 @@ -use key_watch::cli::CliOptions; +use key_watch::cli::{CliOptions, HookInstallArgs, HookType}; use key_watch::hooks::{generate_pre_commit_hook, generate_pre_push_hook}; +fn hook_install_args( + hook_type: HookType, + allowed_repos: Option<&str>, + blocked_repos: Option<&str>, + exclude: Option<&str>, +) -> HookInstallArgs { + HookInstallArgs { + hook_type, + global: false, + allowed_repos: allowed_repos.map(str::to_string), + blocked_repos: blocked_repos.map(str::to_string), + exclude: exclude.map(str::to_string), + } +} + #[test] fn test_hook_generation_pre_commit() { - let options = CliOptions { - file: vec![], - dir: None, - output: None, - verbose: false, - allowed_repos: None, - blocked_repos: None, - exclude: Some("*.log,*.tmp".to_string()), - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, - }; + let options = hook_install_args(HookType::PreCommit, None, None, Some("*.log,*.tmp")); let hook = generate_pre_commit_hook(&options); assert!(hook.contains("#!/bin/bash"), "Should be bash shebang"); @@ -28,22 +32,15 @@ fn test_hook_generation_pre_commit() { hook.contains("EXCLUDE_PATTERNS='*.log,*.tmp'"), "Should preserve comma-separated exclude patterns" ); + assert!( + hook.contains("scan \"$file\""), + "Should use scan subcommand" + ); } #[test] fn test_hook_generation_pre_push() { - let options = CliOptions { - file: vec![], - dir: None, - output: None, - verbose: false, - allowed_repos: Some("github.com".to_string()), - blocked_repos: None, - exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, - }; + let options = hook_install_args(HookType::PrePush, Some("github.com"), None, None); let hook = generate_pre_push_hook(&options); assert!(hook.contains("#!/bin/bash"), "Should be bash shebang"); @@ -52,6 +49,10 @@ fn test_hook_generation_pre_push() { "Should shell-quote binary assignment" ); assert!(hook.contains("ALLOWED_REPOS"), "Should set allowed repos"); + assert!( + hook.contains("scan . --exit-mode critical"), + "Should use scan subcommand for pre-push" + ); assert!( hook.contains("CURRENT_REMOTE=$(git remote get-url --push origin"), "Should enforce repo restrictions" @@ -60,18 +61,12 @@ fn test_hook_generation_pre_push() { #[test] fn test_hook_shell_escaping() { - let options = CliOptions { - file: vec![], - dir: None, - output: None, - verbose: false, - allowed_repos: Some("ghp_test'repos123".to_string()), - blocked_repos: None, - exclude: Some("test*.txt".to_string()), - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, - }; + let options = hook_install_args( + HookType::PrePush, + Some("ghp_test'repos123"), + None, + Some("test*.txt"), + ); let hook = generate_pre_push_hook(&options); assert!( @@ -82,18 +77,7 @@ fn test_hook_shell_escaping() { #[test] fn test_hook_missing_binary_path() { - let options = CliOptions { - file: vec![], - dir: None, - output: None, - verbose: false, - allowed_repos: None, - blocked_repos: None, - exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, - }; + let options = hook_install_args(HookType::PrePush, None, None, None); let hook = generate_pre_push_hook(&options); assert!( @@ -108,18 +92,7 @@ fn test_hook_missing_binary_path() { #[test] fn test_hook_missing_detectors_toml() { - let options = CliOptions { - file: vec![], - dir: None, - output: None, - verbose: false, - allowed_repos: None, - blocked_repos: None, - exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, - }; + let options = hook_install_args(HookType::PreCommit, None, None, None); let hook = generate_pre_commit_hook(&options); assert!( @@ -127,3 +100,73 @@ fn test_hook_missing_detectors_toml() { "Hook should rely on binary config lookup" ); } + +#[test] +fn test_cli_global_hook_requires_install_hook() { + use clap::Parser; + + let result = CliOptions::try_parse_from(["key-watch", "scan", "secret.txt", "--global"]); + assert!(result.is_err(), "--global should be rejected for scan"); +} + +#[test] +fn test_cli_global_uninstall_hook_requires_hook_target() { + use clap::Parser; + + let result = + CliOptions::try_parse_from(["key-watch", "hook", "uninstall", "pre-commit", "--global"]); + assert!(result.is_ok(), "--global should work with hook uninstall"); +} + +#[test] +fn test_cli_pre_commit_rejects_repo_filters() { + use clap::Parser; + + let options = CliOptions::try_parse_from([ + "key-watch", + "hook", + "install", + "pre-commit", + "--allowed-repos", + "github.com/example/repo", + ]) + .expect("clap parsing should succeed"); + + let error = options + .validate() + .expect_err("pre-commit should reject repo filters"); + assert!( + error.contains("--allowed-repos and --blocked-repos are only supported for pre-push hooks") + ); +} + +#[test] +fn test_cli_pre_push_rejects_exclude() { + use clap::Parser; + + let options = CliOptions::try_parse_from([ + "key-watch", + "hook", + "install", + "pre-push", + "--exclude", + "target", + ]) + .expect("clap parsing should succeed"); + + let error = options + .validate() + .expect_err("pre-push should reject exclude patterns"); + assert!(error.contains("--exclude is only supported for pre-commit hooks")); +} + +#[test] +fn test_cli_init_conflicts_with_scan_targets() { + use clap::Parser; + + let result = CliOptions::try_parse_from(["key-watch", "init", "bash", "secret.txt"]); + assert!( + result.is_err(), + "init should reject extra positional scan targets" + ); +} diff --git a/tests/scanner_tests.rs b/tests/scanner_tests.rs index 6c7a653..d7c1349 100644 --- a/tests/scanner_tests.rs +++ b/tests/scanner_tests.rs @@ -1,4 +1,4 @@ -use key_watch::cli::CliOptions; +use key_watch::cli::{ExitMode, ScanArgs}; use key_watch::scanner::run_scan; use std::env::temp_dir; use std::fs; @@ -18,17 +18,12 @@ sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX\n\ "; fs::write(&test_file, content).expect("Unable to write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -49,17 +44,12 @@ Stripe: sk_test_51ABCDEF12345678901234567890\n\ "; fs::write(&test_file, content).expect("Unable to write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -81,17 +71,12 @@ AZURE_STORAGE=DefaultEndpointsProtocol=https;AccountName=examplestore; "; fs::write(&test_file, content).expect("Unable to write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -114,17 +99,12 @@ b3BlbnNzaC1ldi0xLjAAABgQDQD2FGB3V2t4=\n\ "; fs::write(&test_file, content).expect("Unable to write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -141,17 +121,12 @@ fn test_multiple_detections_in_line() { let content = "password=secret email=user@example.com key=AKIATESTKEY123"; fs::write(&test_file, content).expect("Unable to write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -180,17 +155,12 @@ fn test_directory_scan_with_exclusions() { fs::create_dir_all(test_dir.join(".git")).expect("Create .git dir"); fs::write(test_dir.join(".git/secret.txt"), "SHOULD_NOT_FIND").expect("Write git file"); - let options = CliOptions { - file: vec![], - dir: Some(test_dir.to_str().unwrap().to_string()), + let options = ScanArgs { + paths: vec![test_dir.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, metadata) = run_scan(&options).expect("run_scan should succeed"); @@ -218,17 +188,12 @@ fn test_exclude_pattern_filtering() { fs::write(test_dir.join("secret.txt"), "password=secret123").expect("Write secret"); fs::write(test_dir.join("debug.log"), "password=debug123").expect("Write log"); - let options = CliOptions { - file: vec![], - dir: Some(test_dir.to_str().unwrap().to_string()), + let options = ScanArgs { + paths: vec![test_dir.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: Some("*.log".to_string()), - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (_findings, metadata) = run_scan(&options).expect("run_scan should succeed"); @@ -258,17 +223,12 @@ fn test_dot_github_directory_is_scanned() { fs::create_dir_all(test_dir.join(".github")).expect("Create .github dir"); fs::write(test_dir.join(".github/workflow.txt"), "password=secret123").expect("Write file"); - let options = CliOptions { - file: vec![], - dir: Some(test_dir.to_str().unwrap().to_string()), + let options = ScanArgs { + paths: vec![test_dir.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, metadata) = run_scan(&options).expect("run_scan should succeed"); @@ -284,17 +244,12 @@ fn test_scan_no_secrets() { let content = "This is a plain text file.\nThere is nothing secret here."; fs::write(&temp_file, content).expect("Unable to write no-secret file"); - let options = CliOptions { - file: vec![temp_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![temp_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -311,17 +266,12 @@ fn test_non_utf8_file_handling() { let content: Vec = vec![0x80, 0x81, 0x82, 0xff, 0xfe]; fs::write(&test_file, content).expect("Unable to write binary test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -332,11 +282,6 @@ fn test_non_utf8_file_handling() { #[test] fn test_multiple_files_scan() { - use key_watch::cli::CliOptions; - use key_watch::scanner::run_scan; - use std::env::temp_dir; - use std::fs; - let temp_dir = temp_dir(); let test_file1 = temp_dir.join("keywatch_multi_test1.txt"); let test_file2 = temp_dir.join("keywatch_multi_test2.txt"); @@ -344,20 +289,15 @@ fn test_multiple_files_scan() { fs::write(&test_file1, "AWS_KEY=AKIATESTMULTI123").expect("Write test file 1"); fs::write(&test_file2, "password=secretpassword123").expect("Write test file 2"); - let options = CliOptions { - file: vec![ + let options = ScanArgs { + paths: vec![ test_file1.to_str().unwrap().to_string(), test_file2.to_str().unwrap().to_string(), ], - dir: None, output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, metadata) = run_scan(&options).expect("run_scan should succeed"); @@ -379,17 +319,12 @@ fn test_detect_aadhaar() { let content = "My Aadhaar: 1234-5678-9012\nBackup: 1234 5678 9012\nNo space: 123456789012"; fs::write(&test_file, content).expect("Write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -413,17 +348,12 @@ fn test_detect_voter_id() { let content = "Voter ID: ABC1234567\nAnother: XYZ9876543"; fs::write(&test_file, content).expect("Write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -444,17 +374,12 @@ fn test_detect_pan_card() { let content = "PAN: ABCDE1234F\nBackup PAN: PQRST5678G"; fs::write(&test_file, content).expect("Write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -475,17 +400,12 @@ fn test_detect_abha() { let content = "ABHA: 1234-5678-9012-34\nMy Health ID: 9876-5432-1098-76"; fs::write(&test_file, content).expect("Write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -507,17 +427,12 @@ fn test_multiple_indian_ids() { "Aadhaar: 9999-8888-7777\nVoter ID: ABC1234567\nPAN: XYZZU1234A\nABHA: 1111-2222-3333-44"; fs::write(&test_file, content).expect("Write test file"); - let options = CliOptions { - file: vec![test_file.to_str().unwrap().to_string()], - dir: None, + let options = ScanArgs { + paths: vec![test_file.to_str().unwrap().to_string()], output: None, verbose: false, - allowed_repos: None, - blocked_repos: None, exclude: None, - install_hook: None, - exit_mode: "strict".to_string(), - verify_integrity: false, + exit_mode: ExitMode::Strict, }; let (findings, _) = run_scan(&options).expect("run_scan should succeed"); @@ -542,3 +457,55 @@ fn test_multiple_indian_ids() { fs::remove_file(test_file).expect("Cleanup"); } + +#[test] +fn test_overlapping_scan_roots_with_exclusions() { + let temp_dir = temp_dir(); + let root1 = temp_dir.join(format!( + "keywatch_overlapping_1_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir(&root1).expect("Create test directory 1"); + + let root2 = root1.join("subdir"); + fs::create_dir(&root2).expect("Create test directory 2"); + + let test_file = root2.join("secret.txt"); + fs::write(&test_file, "password=secret123").expect("Write test file"); + + // The exclude pattern "subdir/secret.txt" should match relative to root1, + // or just "secret.txt" should match relative to root2. + // Let's exclude "subdir/secret.txt". It matches relative to root1. + // If root2 is used as the only root, "secret.txt" stripped of root2 is "secret.txt", + // which does NOT match "subdir/secret.txt", so it would NOT be excluded and find the secret! + let options = ScanArgs { + paths: vec![ + root2.to_str().unwrap().to_string(), // Root 2 comes first to try to mess up order + root1.to_str().unwrap().to_string(), + ], + output: None, + verbose: false, + exclude: Some("subdir/secret.txt".to_string()), + exit_mode: ExitMode::Strict, + }; + + let (findings, metadata) = run_scan(&options).expect("run_scan should succeed"); + + // Because we exclude "subdir/secret.txt", it should be excluded via root1's perspective. + assert!( + metadata + .excluded_files + .iter() + .any(|f| f.contains("secret.txt")), + "File should be excluded despite overlapping roots" + ); + assert!( + findings.is_empty(), + "No findings should be present because the file was excluded" + ); + + fs::remove_dir_all(root1).expect("Cleanup"); +}