diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index a97fae7..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,22 +0,0 @@ -repos: - - repo: local - hooks: - - id: keywatch - name: KeyWatch Secret Scanner - entry: ./hooks/keywatch.sh - language: system - files: .*\.(rs|txt|py|js)$ - - - id: cargo-fmt-check - name: "Check Rust formatting with cargo fmt" - entry: cargo +nightly fmt --all -- --check - language: system - types: [rust] - files: .*\.(rs)$ - - - id: cargo-clippy - name: "Check Rust code with clippy" - entry: cargo clippy --all-targets --all-features -- -D warnings - language: system - types: [rust] - files: .*\.(rs)$ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e019ea9..e98ff0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,40 @@ # Changelog +All notable changes to this project will be documented in this file. + ## [Unreleased] +### Added + +- Binary aliases: `keywatch`, `watch` (in addition to `key-watch`) +- Exit code modes: `--exit-mode always|critical|strict` +- Binary integrity verification: `--verify-integrity` +- Repository controls: `--allowed-repos`, `--blocked-repos` + +### Security + +- Shell injection protection in generated hooks +- Non-UTF8 file handling (graceful skip) + +### Changed + +- Simplified README (~60 lines) +- User-friendly output by default (summary, not JSON) +- Default exit mode: strict + +### Fixed + +- Portable detector loading (exe-relative path) +- Filenames with spaces handling + +### Removed + +- Legacy `hooks/keywatch.sh` +- `.pre-commit-config.yaml` + ## [1.0.0] - 2025-02-16 -- Initial Release - - Support reading file, directory with verbose output in console - - Output results to a file - - Support various types of keys / secrets / tokens and etc., \ No newline at end of file +- Initial release +- File/directory scanning +- Verbose JSON output +- Pre-commit/pre-push hooks \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 672ade9..aae6129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -28,15 +28,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -73,9 +73,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -97,9 +97,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" @@ -113,11 +113,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -127,9 +133,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.7.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -152,6 +158,7 @@ name = "key-watch" version = "1.0.0" dependencies = [ "clap", + "glob", "regex", "serde", "serde_json", @@ -172,27 +179,27 @@ checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -202,9 +209,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -218,25 +225,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] -name = "ryu" -version = "1.0.19" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] -name = "serde" -version = "1.0.219" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -245,23 +256,24 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -272,9 +284,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -283,44 +295,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "unicode-ident" @@ -409,9 +419,12 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.10" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" -dependencies = [ - "memchr", -] +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bd1df63..df7be5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,26 @@ [package] name = "key-watch" version = "1.0.0" -edition = "2021" +edition = "2024" authors = ["Pa1Nark "] license = "GPL-3.0" +[[bin]] +name = "key-watch" +path = "src/main.rs" + +[[bin]] +name = "keywatch" +path = "src/main.rs" + +[[bin]] +name = "watch" +path = "src/main.rs" + [dependencies] -clap = { version = "4.5.40", features = ["derive"] } -regex = "1.11.1" -toml = "0.8.23" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" +clap = { version = "4.6.1", features = ["derive"] } +regex = "1.12.3" +toml = "1.1.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +glob = "0.3.3" diff --git a/README.md b/README.md index 5ed4021..429d0ab 100644 --- a/README.md +++ b/README.md @@ -1,295 +1,70 @@ # KeyWatch -KeyWatch is a secret scanner written in Rust that analyzes files or directories for secrets such as API keys, passwords, tokens, and more. It leverages a flexible and configurable set of detectors (defined via a TOML configuration) to help you secure your codebase by catching accidental exposures early. Whether you’re integrating it into your CI/CD pipeline or using it as a pre-commit hook, KeyWatch is designed to be fast, efficient, and easily extendable. +A fast secret scanner for files and directories. -## Table of Contents +## Install -- [Features](#features) -- [Project Structure](#project-structure) -- [Installation](#installation) - - [Prerequisites](#prerequisites) - - [Building from Source](#building-from-source) - - [Installing the Binary](#installing-the-binary) -- [Usage](#usage) - - [Scanning Files and Directories](#scanning-files-and-directories) - - [Windows Users](#windows-users) -- [Adding More Detectors](#adding-more-detectors) -- [Integration with pre-commit](#integrating-keywatch-with-pre-commit) -- [Running Tests](#running-tests) -- [License](#license) - -## Features - -- **Recursive Scanning:** Easily scan a single file or an entire directory recursively to detect potential security breaches. -- **Comprehensive Detection:** The built-in detectors cover AWS keys, Google API keys, Slack tokens, JWT tokens, SSH keys, passwords, email addresses, IP addresses, and many more. -- **Configurable Detectors:** The detection logic is defined in [`detectors.toml`], which is simple to extend or customize according to your needs. -- **Output Options:** Generate JSON-formatted reports that can be directed to the console (in verbose mode) or saved to a file. -- **Integration Ready:** Designed to integrate with CI/CD pipelines, pre-commit hooks, or any other automated workflow. - -## Project Structure - -The KeyWatch project is organized as follows: - -```txt -KeyWatch/ -├── .gitignore # Specifies intentionally untracked files to ignore. -├── Cargo.lock # Cargo's lock file ensuring reproducible builds. -├── Cargo.toml # Project manifest (dependencies, metadata, etc.) -├── LICENSE # MIT License file. -├── README.md # This documentation file. -├── detectors.toml # Configuration file defining secret detectors and regex patterns. -├── src -│ ├── cli.rs // Contains CLI definitions using clap. -│ ├── detector.rs // Implements secret detectors and regex patterns. -│ ├── lib.rs // Re-exports modules for integration testing. -│ ├── main.rs // Application entry point. -│ ├── report.rs // Generates JSON reports from scan results. -│ ├── scanner.rs // Implements file and directory scanning. -│ └── utils.rs // Contains utility functions (e.g., file I/O). -└── tests - └── integration_tests.rs // Integration tests for end-to-end functionality. -``` +```sh +# Recommended +cargo install --git https://github.com/pixincreate/KeyWatch.git -The relationships between key modules are illustrated below: +# Or use the install script +./scripts/install.sh -```mermaid -graph TD - A[main.rs] --> B[cli.rs] - A --> C[scanner.rs] - C --> D[detector.rs] - D --> E[detectors.toml] - C --> F[report.rs] - A --> G[utils.rs] +# Manual: download binary, add to PATH ``` -## Installation - -### Prerequisites - -- [Rust](https://www.rust-lang.org/tools/install) (version 1.70 or later) must be installed on your system. -- Linux and macOS users: Standard Unix tools (`grep`, `chmod`, etc.) should be available. -- Windows users: Consider installing Git Bash or enabling Windows Subsystem for Linux (WSL2) for an enhanced Unix-like experience, though native Windows commands work as well. - -### Building from Source - -1. Clone the repository: - - ```sh - git clone https://github.com/pixincreate/KeyWatch.git - cd KeyWatch - ``` - -2. Build the project using Cargo: - - ```sh - cargo build - ``` - - This command compiles the KeyWatch binary into the `target/debug` directory. - -### Installing the Binary - -You can install KeyWatch globally so it is available from any command prompt: - -1. **Cargo Install (Recommended):** - - Run the following command from the KeyWatch directory: - - ```sh - cargo install --path . - ``` - -> [!NOTE] -> This command copies the binary to Cargo’s bin directory (typically `~/.cargo/bin` on Unix or `%USERPROFILE%\.cargo\bin` on Windows), which should be part of your `PATH` already. -> This will let you invoke the binary simply by typing `key-watch`. - -2. **Manual Installation:** - - You may manually copy the binary into a directory included in your PATH: - - - **For Unix-based systems (Linux/macOS):** - - ```sh - cp target/debug/key-watch /usr/local/bin - ``` - - Or create a symbolic link: - - ```sh - ln -s /path/to/target/release/key-watch /usr/local/bin/key-watch - ``` - - - **For Windows (PowerShell):** - - 1. Navigate to the release directory: - - ```ps1 - cd target\release - ``` - - 2. Copy the binary (e.g., `key-watch.exe`) to a directory that is part of your PATH (such as `C:\Program Files\KeyWatch`—ensure that directory is added to your PATH): - - ```ps1 - Copy-Item -Path "key-watch.exe" -Destination "C:\Program Files\KeyWatch\key-watch.exe" - ``` - - You can also add the `–Force` parameter if you want to overwrite the destination file without any prompts - - 3. Alternatively, you can add `%USERPROFILE%\.cargo\bin` to your system `PATH` if it’s not already included. This is where Cargo installs binaries by default. - ## Usage -### Scanning Files and Directories - -After installing or building the binary, you can start scanning files for secrets: - -- **Scanning a Single File (Output to Console):** - - ```sh - cargo run -- --file ./path/to/your/file --verbose - ``` - - This command scans the specified file and prints a detailed JSON report to the console. - -- **Recursively Scanning a Directory (Output to File):** - - ```sh - cargo run -- --dir ./path/to/your/directory --output results.json - ``` - - The scanner will recursively inspect all eligible files within the directory tree, and the JSON report will be written to `results.json`. - -### Windows Users - -KeyWatch works well on Windows with a few adjustments: - -- **Using Command Prompt or PowerShell:** - The commands above work in either Command Prompt or PowerShell (preferred). Just ensure that Rust and Cargo are in your `PATH`, and that when installed via cargo, your binaries are located in `%USERPROFILE%\.cargo\bin`. - -- **Windows Environment Tips:** - - - If using PowerShell, remember to escape arguments properly if needed. - - For better Unix-like behavior, consider installing Git Bash which provides a more consistent experience with the documentation examples. - - If integrating KeyWatch with Windows-based CI systems (e.g., Azure Pipelines), you may need to adjust the shell commands accordingly. - -- **Running on Windows:** - - To run KeyWatch on a specific file from Command Prompt: - - ```cmd - key-watch --file "C:\path\to\your\file" --verbose - ``` - - Or to scan a directory recursively: - - ```cmd - key-watch --dir "C:\path\to\your\directory" --output "C:\path\to\results.json" - ``` - -## Adding More Detectors - -KeyWatch uses a flexible detector system configured via the [`detectors.toml`] file. You can modify this file to add new secret detectors or adjust the regular expressions and configurations of existing ones. For example: - -- Open `detectors.toml` in your preferred editor. -- Define a new section with a unique identifier for your custom detector. -- Provide the regex patterns, severity levels, and any additional metadata necessary. - -This design means you can continuously tailor KeyWatch to meet the needs of your security policies. - -## Integrating KeyWatch with pre-commit - -Integrate KeyWatch into your development workflow by setting it up as a pre-commit hook. This ensures that any secrets accidentally committed to your repository get caught immediately. - -1. **Install pre-commit:** - - Ensure Python is installed on your system, then use pip: - - ```sh - pip install pre-commit - ``` - -> [!NOTE] -> Make sure that you have the `pre-commit` binary in your PATH. - -2. **Create the Hook Script:** - - 1. Make a hooks directory in your project root: - - ```sh - mkdir -p hooks - ``` - - 2. Create a file named `hooks/keywatch.sh` with the following content: - - ```sh - #!/bin/sh - - EXIT_CODE=0 - for FILE in "$@"; do - # Only scan text files - if file "$FILE" | grep -q text; then - echo "Scanning $FILE for secrets..." - REPORT=$(key-watch --file "$FILE" --verbose) - if echo "$REPORT" | grep -q '"status": "FAIL"'; then - echo "Secret found in $FILE:" - echo "$REPORT" - EXIT_CODE=1 - fi - fi - done - exit $EXIT_CODE - ``` - - 3. Make the script executable: - - ```sh - chmod +x hooks/keywatch.sh - ``` +```sh +# Scan a file +keywatch --file secrets.txt -3. **Configure pre-commit:** +# Scan a directory +keywatch --dir . - Create a `.pre-commit-config.yaml` file in your project root with these contents: +# Verbose output (JSON) +keywatch --file secrets.txt --verbose - ```yaml - repos: - - repo: local - hooks: - - id: keywatch - name: KeyWatch Secret Scanner - entry: ./hooks/keywatch.sh - language: script - files: .*\.(rs|txt|py|js)$ # Adjust the pattern as necessary - ``` +# Install git hook +keywatch --install-hook pre-commit +keywatch --install-hook pre-push +``` -4. **Install the pre-commit Hooks:** +## Options - Run the following command to install the hook into your local Git configuration: +- `--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) - ```sh - pre-commit install - ``` +## Aliases -5. **Test the Integration:** +`key-watch`, `keywatch`, `watch` are equivalent. - To see the hook in action, stage files with potential secrets and try committing: +## Exit Codes - ```sh - git add - git commit -m "Test commit: should run secret scanner" - ``` +| Code | Meaning | +|------|---------| +| 0 | No secrets found (or `--exit-mode always`) | +| 1 | Secret found (in strict/critical mode) | - If KeyWatch detects a secret, the commit will be blocked with a detailed error message. Correct the issue (or update your detector configuration) and try committing again. +## Default Behavior -## Running Tests +- **Repos**: All allowed (no restrictions) +- **Exit mode**: strict (fail on any finding) -KeyWatch comes with integration tests located in the `/tests` directory. To run all tests, execute: +## Development ```sh +cargo build --release cargo test -``` - -This command will run the complete suite of tests ensuring that the scanning and reporting components behave as expected. - -## License - -KeyWatch is distributed under the terms of the [MIT License](LICENSE), which means you’re free to use and modify the software as long as the license terms are met. +cargo fmt +cargo clippy +``` \ No newline at end of file diff --git a/hooks/keywatch.sh b/hooks/keywatch.sh deleted file mode 100755 index d4c1a66..0000000 --- a/hooks/keywatch.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -EXIT_CODE=0 -for FILE in "$@"; do - # Only scan text files - if file "$FILE" | grep -q text; then - echo "Scanning $FILE for secrets..." - REPORT=$(key-watch --file "$FILE" --verbose) - if echo "$REPORT" | grep -q '"status": "FAIL"'; then - echo "Secret found in $FILE:" - echo "$REPORT" - EXIT_CODE=1 - fi - fi -done -exit $EXIT_CODE diff --git a/justfile b/justfile new file mode 100644 index 0000000..d799714 --- /dev/null +++ b/justfile @@ -0,0 +1,83 @@ +# KeyWatch justfile + +default: + @just --list + +# Run the application +run *args="": + cargo run {{args}} + +# Build release binary +build: + cargo build --release + +# Run all tests +test: + cargo test + +# Run tests with output +test-v: + cargo test -- --nocapture + +# Run specific test +test-named name: + cargo test {{name}} + +# Format code +fmt: + cargo +nightly fmt --all + +# Lint with clippy +clippy: + cargo clippy --all-targets --all-features + +# Run clippy with warnings as errors +clippy-strict: + cargo clippy --all-targets --all-features -- -D warnings + +# Build and run release +run-release: + cargo run --release + +# Full check pipeline +check: fmt clippy test + @echo "✓ All checks passed" + +# Run benchmarks (requires criterion) +bench: + cargo bench + +# Generate docs +doc: + cargo doc --no-deps --open + +# Build docs without opening +doc-build: + cargo doc --no-deps + +# Add a new dependency +add dep: + cargo add {{dep}} + +# Remove a dependency +remove dep: + cargo remove {{dep}} + +# Update dependencies +update: + cargo update + +# Audit dependencies for vulnerabilities +audit: + cargo audit + +# Clean build artifacts +clean: + cargo clean + +# Wipe and rebuild from scratch +scrub: clean build + +# Check available targets +targets: + cargo metadata --format-version 1 | jq '.targets[] | select(.kind[0] == "bin") | .name' -r \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..738d312 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,60 @@ +#!/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/cli.rs b/src/cli.rs index 37ec046..a44633f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,7 @@ use clap::{ArgGroup, Parser}; #[command(group( ArgGroup::new("target") .required(true) - .args(&["file", "dir"]), + .args(&["file", "dir", "install_hook"]), ))] pub struct CliOptions { /// Scan a single file @@ -24,4 +24,35 @@ pub struct CliOptions { /// Print the scan results to the console #[arg(short, long, default_value_t = false)] pub verbose: bool, + + /// Allowed repository URLs (comma-separated) + /// Experimental: Push to these repos will be allowed + #[arg(long)] + pub allowed_repos: Option, + + /// Blocked repository URLs (comma-separated) + /// Experimental: Push to these repos will be blocked + #[arg(long)] + pub blocked_repos: Option, + + /// Paths to exclude from scanning (comma-separated, supports glob patterns) + #[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, + + /// 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, + + /// Verify binary integrity on startup + #[arg(long, default_value_t = false)] + pub verify_integrity: bool, } diff --git a/src/detector.rs b/src/detector.rs index 65c82ea..b87befb 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -11,13 +11,18 @@ pub struct Detector { } impl Detector { - pub fn new(name: &str, pattern: &str, finding_type: &str, severity: &str) -> Detector { - Detector { + pub fn new( + name: &str, + pattern: &str, + finding_type: &str, + severity: &str, + ) -> Result { + Ok(Detector { name: name.to_string(), - regex: Regex::new(pattern).unwrap(), + regex: Regex::new(pattern)?, finding_type: finding_type.to_string(), severity: severity.to_string(), - } + }) } } @@ -35,18 +40,32 @@ struct DetectorConfig { severity: String, } +fn find_detectors_config() -> Result { + if let Ok(exe_path) = std::env::current_exe() + && let Some(exe_dir) = exe_path.parent() + { + let config_path = exe_dir.join("detectors.toml"); + if config_path.exists() { + return Ok(config_path); + } + } + + Ok(std::path::PathBuf::from("detectors.toml")) +} + /// initialize_detectors reads the detector definitions from detectors.toml and returns a vector of Detector. -/// You can adjust the path to the TOML file as needed. -pub fn initialize_detectors() -> Vec { - let toml_contents = fs::read_to_string("detectors.toml") - .expect("Failed to read detectors.toml configuration file"); +pub fn initialize_detectors() -> Result, Box> { + let config_path = find_detectors_config()?; + let toml_contents = fs::read_to_string(&config_path) + .map_err(|err| format!("Failed to read {}: {}", config_path.display(), err))?; - let config: DetectorsConfig = - toml::from_str(&toml_contents).expect("Failed to parse detectors.toml"); + let config: DetectorsConfig = toml::from_str(&toml_contents) + .map_err(|err| format!("Failed to parse detectors.toml: {}", err))?; - config + Ok(config .detectors .into_iter() .map(|det| Detector::new(&det.name, &det.pattern, &det.finding_type, &det.severity)) - .collect() + .collect::, _>>() + .map_err(|err| format!("Invalid detector pattern: {}", err))?) } diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..ed42843 --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,75 @@ +use crate::cli::CliOptions; + +const PRE_PUSH_TEMPLATE: &str = include_str!("../templates/pre-push.sh"); +const PRE_COMMIT_TEMPLATE: &str = include_str!("../templates/pre-commit.sh"); + +const SAFE_CHARS: &str = "-_./@"; +const DEFAULT_BINARY_NAME: &str = "key-watch"; + +fn shell_escape(input: &str) -> String { + input + .replace('\'', "'\"'\"'") + .chars() + .filter(|ch| ch.is_alphanumeric() || SAFE_CHARS.contains(*ch)) + .collect() +} + +fn build_repo_section(allowed: Option<&str>, blocked: Option<&str>) -> String { + let escaped_allowed = allowed.map(shell_escape); + let escaped_blocked = blocked.map(shell_escape); + + if escaped_allowed.is_none() && escaped_blocked.is_none() { + return String::new(); + } + + let allowed_line = escaped_allowed.map_or(String::new(), |escaped| { + format!("ALLOWED_REPOS='{}'\n", escaped) + }); + let blocked_line = escaped_blocked.map_or(String::new(), |escaped| { + format!("BLOCKED_REPOS='{}'\n", escaped) + }); + format!("{}{}", allowed_line, blocked_line) +} + +fn render_pre_push(options: &CliOptions) -> String { + let binary_name = hook_binary_name(); + let repo_section = build_repo_section( + options.allowed_repos.as_deref(), + options.blocked_repos.as_deref(), + ); + + PRE_PUSH_TEMPLATE + .replace("{{binary_name}}", &binary_name) + .replace("{{repo_section}}", &repo_section) +} + +fn render_pre_commit(options: &CliOptions) -> String { + let binary_name = hook_binary_name(); + let exclude_patterns = options + .exclude + .as_deref() + .map(shell_escape) + .unwrap_or_default(); + + PRE_COMMIT_TEMPLATE + .replace("{{binary_name}}", &binary_name) + .replace("{{exclude_patterns}}", &exclude_patterns) +} + +pub fn generate_pre_push_hook(options: &CliOptions) -> String { + render_pre_push(options) +} + +pub fn generate_pre_commit_hook(options: &CliOptions) -> String { + render_pre_commit(options) +} + +fn hook_binary_name() -> String { + std::env::current_exe() + .ok() + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| DEFAULT_BINARY_NAME.to_string()) +} diff --git a/src/lib.rs b/src/lib.rs index 2190f19..a3a78f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,8 @@ pub mod cli; pub mod detector; +pub mod hooks; pub mod report; pub mod scanner; pub mod utils; + +pub use hooks::{generate_pre_commit_hook, generate_pre_push_hook}; diff --git a/src/main.rs b/src/main.rs index fc1a22a..4659da5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,31 +1,141 @@ mod cli; mod detector; +mod hooks; mod report; mod scanner; mod utils; use clap::Parser; use cli::CliOptions; +use hooks::{generate_pre_commit_hook, generate_pre_push_hook}; +use report::Finding; use scanner::run_scan; +use std::env; use std::time::Instant; +// Constants for exit modes +const EXIT_MODE_ALWAYS: &str = "always"; +const EXIT_MODE_CRITICAL: &str = "critical"; +const EXIT_MODE_STRICT: &str = "strict"; + +// Constants for severity levels +const SEVERITY_HIGH: &str = "HIGH"; + fn main() { + if let Err(err) = run() { + eprintln!("Error: {}", err); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { let options = CliOptions::parse(); let start = Instant::now(); - let (findings, scan_metadata) = run_scan(&options); + + if let Some(hook_type) = &options.install_hook { + install_hook(hook_type, &options)?; + return Ok(()); + } + + if options.verify_integrity { + verify_binary_integrity()?; + } + + let (findings, scan_metadata) = run_scan(&options)?; let elapsed = start.elapsed(); let scan_time = format!( "{}.{:01}s", elapsed.as_secs(), elapsed.subsec_millis() / 100 ); - let report_json = report::create_report(findings, scan_metadata, scan_time); + let report_json = report::create_report(findings.clone(), scan_metadata, scan_time) + .map_err(|err| format!("Failed to serialize report: {}", err))?; + if options.verbose { - println!("{}", report_json); + println!("{report_json}"); + } else { + let severity_counts = report::get_severity_counts(&findings); + if findings.is_empty() { + println!("No secrets found."); + } else { + println!( + "WARNING: {} potential secret(s) detected (HIGH: {}, MEDIUM: {}, LOW: {})", + findings.len(), + severity_counts.0, + severity_counts.1, + severity_counts.2 + ); + } } + if let Some(ref output_path) = options.output { - if let Err(e) = utils::write_to_file(output_path, &report_json) { - eprintln!("Error writing to file {}: {}", output_path, e); + utils::write_to_file(output_path, &report_json) + .map_err(|err| format!("Failed to write report to '{}': {}", output_path, err))?; + } + + let exit_code = calculate_exit_code(&findings, &options.exit_mode); + std::process::exit(exit_code); +} + +fn install_hook(hook_type: &str, options: &CliOptions) -> Result<(), String> { + let hook_content = match hook_type { + "pre-push" => generate_pre_push_hook(options), + "pre-commit" => generate_pre_commit_hook(options), + _ => { + return Err(format!("Unknown hook type: {}", hook_type)); + } + }; + + let hook_path = format!(".git/hooks/{hook_type}"); + utils::write_to_file(&hook_path, &hook_content) + .map_err(|err| format!("Failed to install hook '{}': {}", hook_type, 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}"); + println!( + "The hook will run automatically during git {}.", + hook_type.replace('-', " ") + ); + Ok(()) +} + +fn verify_binary_integrity() -> Result<(), String> { + let exe_path = + env::current_exe().map_err(|err| format!("Failed to get executable path: {}", err))?; + let metadata = exe_path + .metadata() + .map_err(|err| format!("Failed to get executable metadata: {}", err))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = metadata.permissions(); + let mode = perms.mode(); + if mode & 0o002 != 0 { + eprintln!("WARNING: Binary is world-writable! Integrity may be compromised."); + } + } + + println!("Binary integrity verified: {:?}", exe_path); + println!("Size: {} bytes", metadata.len()); + Ok(()) +} + +fn calculate_exit_code(findings: &[Finding], exit_mode: &str) -> i32 { + if findings.is_empty() { + return 0; + } + + match exit_mode { + EXIT_MODE_ALWAYS => 0, + EXIT_MODE_CRITICAL => { + let has_high = findings + .iter() + .any(|finding| finding.severity == SEVERITY_HIGH); + if has_high { 1 } else { 0 } } + EXIT_MODE_STRICT => 1, + _ => 1, } } diff --git a/src/report.rs b/src/report.rs index 29dc417..ace398f 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,7 +1,7 @@ use serde::Serialize; /// Represents a single secret finding. -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub struct Finding { pub file_path: String, pub line_number: usize, @@ -12,44 +12,55 @@ pub struct Finding { } /// Metadata about the scanning performed. -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub struct ScanMetadata { pub files_scanned: usize, pub total_lines: usize, pub excluded_files: Vec, } -/// ReportMetadata bundles scan metadata with scan_time. -#[derive(Serialize)] -pub struct ReportMetadata { - pub files_scanned: usize, - pub total_lines: usize, - pub excluded_files: Vec, - pub scan_time: String, -} - /// The overall report. #[derive(Serialize)] pub struct Report { pub status: String, pub findings: Vec, - pub scan_metadata: ReportMetadata, + pub files_scanned: usize, + pub total_lines: usize, + pub excluded_files: Vec, + pub scan_time: String, } /// create_report builds the final JSON report based on findings and metadata. -pub fn create_report(findings: Vec, metadata: ScanMetadata, scan_time: String) -> String { +pub fn create_report( + findings: Vec, + metadata: ScanMetadata, + scan_time: String, +) -> Result { let status = if findings.is_empty() { "PASS" } else { "FAIL" }; - let report_metadata = ReportMetadata { + let report = Report { + status: status.into(), + findings, files_scanned: metadata.files_scanned, total_lines: metadata.total_lines, excluded_files: metadata.excluded_files, scan_time, }; - let report = Report { - status: status.to_string(), - findings, - scan_metadata: report_metadata, - }; - serde_json::to_string_pretty(&report).unwrap() + serde_json::to_string_pretty(&report) +} + +pub fn get_severity_counts(findings: &[Finding]) -> (usize, usize, usize) { + let high = findings + .iter() + .filter(|finding| finding.severity == "HIGH") + .count(); + let medium = findings + .iter() + .filter(|finding| finding.severity == "MEDIUM") + .count(); + let low = findings + .iter() + .filter(|finding| finding.severity == "LOW") + .count(); + (high, medium, low) } diff --git a/src/scanner.rs b/src/scanner.rs index dc97cfb..f8c4388 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -1,12 +1,10 @@ use crate::cli::CliOptions; use crate::detector::initialize_detectors; use crate::report::{Finding, ScanMetadata}; +use glob::Pattern; use std::fs; -use std::io::{BufRead, BufReader}; -/// run_scan executes the secret scan based on the provided CLI options. -/// Returns a vector of findings and metadata about the scan. -pub fn run_scan(options: &CliOptions) -> (Vec, ScanMetadata) { +pub fn run_scan(options: &CliOptions) -> Result<(Vec, ScanMetadata), String> { let mut findings = Vec::new(); let mut files_scanned = 0; let mut total_lines = 0; @@ -14,38 +12,79 @@ pub fn run_scan(options: &CliOptions) -> (Vec, ScanMetadata) { let mut target_paths = Vec::new(); - // Collect target files from --file or --dir. if let Some(ref file_path) = options.file { target_paths.push(file_path.clone()); } else if let Some(ref dir_path) = options.dir { collect_files(dir_path, &mut target_paths); } - // Initialize our improved detectors - let detectors = initialize_detectors(); + let detectors = initialize_detectors().map_err(|err| err.to_string())?; + let (multiline_detectors, line_detectors): (Vec<_>, Vec<_>) = detectors + .iter() + .partition(|detector| detector.regex.as_str().contains("(?s)")); + + let exclude_patterns: Vec = options + .exclude + .as_ref() + .map(|exclude_str| { + exclude_str + .split(',') + .filter(|pattern| !pattern.trim().is_empty()) + .map(|pattern| { + Pattern::new(pattern.trim()) + .map_err(|err| format!("Invalid exclude pattern '{}': {}", pattern, err)) + }) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); for path in target_paths { - // Exclude files whose path contains ".git" if path.contains(".git") { - excluded_files.push(path.clone()); + excluded_files.push(path); + continue; + } + + let should_exclude = exclude_patterns + .iter() + .any(|pattern| pattern.matches(&path)); + + if should_exclude { + excluded_files.push(path); continue; } files_scanned += 1; - // Read entire file content once. - let full_content = fs::read_to_string(&path).unwrap_or_default(); + let full_content = match fs::read(&path) { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(content) => content, + Err(_) => continue, + }, + Err(_) => continue, + }; + + for detector in &multiline_detectors { + if let Some(mat) = detector.regex.find(&full_content) { + let line_number = full_content[..mat.start()].matches('\n').count() + 1; + findings.push(Finding { + file_path: path.clone(), + line_number, + matched_content: mat.as_str().to_string(), + finding_type: detector.finding_type.clone(), + severity: detector.severity.clone(), + plugin_name: detector.name.clone(), + }); + } + } - // First pass: apply detectors that require multi-line scanning. - for detector in detectors.iter() { - // Use a simple flag choice: if the detector regex pattern contains "(?s)" - if detector.regex.as_str().contains("(?s)") { - if let Some(mat) = detector.regex.find(&full_content) { - // Count the line number by counting newline characters before the match. - let line_number = full_content[..mat.start()].matches('\n').count() + 1; + for (line_idx, line) in full_content.lines().enumerate() { + total_lines += 1; + for detector in &line_detectors { + if let Some(mat) = detector.regex.find(line) { findings.push(Finding { file_path: path.clone(), - line_number, + line_number: line_idx + 1, matched_content: mat.as_str().to_string(), finding_type: detector.finding_type.clone(), severity: detector.severity.clone(), @@ -54,31 +93,6 @@ pub fn run_scan(options: &CliOptions) -> (Vec, ScanMetadata) { } } } - - // Second pass: process file line-by-line for single‑line detectors. - if let Ok(file) = fs::File::open(&path) { - let reader = BufReader::new(file); - for (line_idx, line_result) in reader.lines().enumerate() { - total_lines += 1; - if let Ok(line) = line_result { - // For each detector that is NOT marked for multi-line scanning. - for detector in detectors.iter() { - if !detector.regex.as_str().contains("(?s)") { - if let Some(mat) = detector.regex.find(&line) { - findings.push(Finding { - file_path: path.clone(), - line_number: line_idx + 1, - matched_content: mat.as_str().to_string(), - finding_type: detector.finding_type.clone(), - severity: detector.severity.clone(), - plugin_name: detector.name.clone(), - }); - } - } - } - } - } - } } let metadata = ScanMetadata { @@ -87,10 +101,9 @@ pub fn run_scan(options: &CliOptions) -> (Vec, ScanMetadata) { excluded_files, }; - (findings, metadata) + Ok((findings, metadata)) } -/// Recursively collect files from the given directory. fn collect_files(dir_path: &str, files: &mut Vec) { if let Ok(entries) = fs::read_dir(dir_path) { for entry in entries.flatten() { @@ -99,10 +112,10 @@ fn collect_files(dir_path: &str, files: &mut Vec) { if let Some(path_str) = path.to_str() { files.push(path_str.to_string()); } - } else if path.is_dir() { - if let Some(path_str) = path.to_str() { - collect_files(path_str, files); - } + } else if path.is_dir() + && let Some(path_str) = path.to_str() + { + collect_files(path_str, files); } } } diff --git a/src/utils.rs b/src/utils.rs index d707146..4c083d4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,3 +7,18 @@ pub fn write_to_file(path: &str, content: &str) -> Result<()> { file.write_all(content.as_bytes())?; Ok(()) } + +#[cfg(unix)] +pub fn make_executable(path: &str) -> Result<()> { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) +} + +#[cfg(not(unix))] +pub fn make_executable(_path: &str) -> Result<()> { + Ok(()) +} diff --git a/templates/pre-commit.sh b/templates/pre-commit.sh new file mode 100644 index 0000000..5be5480 --- /dev/null +++ b/templates/pre-commit.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# KeyWatch pre-commit hook +# Installed by KeyWatch + +KEYWATCH_BIN='{{binary_name}}' +EXCLUDE_PATTERNS='{{exclude_patterns}}' + +if ! command -v "$KEYWATCH_BIN" >/dev/null 2>&1; then + echo "Error: key-watch not found on PATH" >&2 + exit 1 +fi + +if [ ! -f "detectors.toml" ]; then + echo "Error: detectors.toml not found in current directory" >&2 + exit 1 +fi + +git diff --cached --name-only | while IFS= read -r file; do + if [ -z "$file" ]; then + continue + fi + if [ ! -f "$file" ]; then + continue + fi + if "$KEYWATCH_BIN" --file "$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 + exit 1 + fi + echo "Error: key-watch failed on $file (exit code: $EXIT_CODE)" >&2 + exit 1 +done + +exit 0 diff --git a/templates/pre-push.sh b/templates/pre-push.sh new file mode 100644 index 0000000..edcb74e --- /dev/null +++ b/templates/pre-push.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# KeyWatch pre-push hook +# Installed by KeyWatch + +{{repo_section}}KEYWATCH_BIN='{{binary_name}}' + +if ! command -v "$KEYWATCH_BIN" >/dev/null 2>&1; then + echo "Error: key-watch not found on PATH" >&2 + exit 1 +fi + +if [ ! -f "detectors.toml" ]; then + echo "Error: detectors.toml not found in current directory" >&2 + exit 1 +fi + +"$KEYWATCH_BIN" --dir . --exit-mode critical +exit $? diff --git a/tests/exit_tests.rs b/tests/exit_tests.rs new file mode 100644 index 0000000..5705bf8 --- /dev/null +++ b/tests/exit_tests.rs @@ -0,0 +1,164 @@ +use key_watch::cli::CliOptions; +use key_watch::scanner::run_scan; +use std::env::temp_dir; +use std::fs; + +#[test] +fn test_exit_code_on_secrets() { + let temp_file = temp_dir().join("keywatch_exit_secrets.txt"); + fs::write(&temp_file, "AWS_KEY=AKIATESTKEY123").expect("Write test file"); + + let options = CliOptions { + file: Some(temp_file.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, + }; + + let (findings, _) = run_scan(&options).expect("Scan should succeed"); + assert!(!findings.is_empty(), "Should find secrets"); + + fs::remove_file(temp_file).expect("Cleanup"); +} + +#[test] +fn test_exit_code_on_no_secrets() { + let temp_file = temp_dir().join("keywatch_exit_no_secrets.txt"); + fs::write(&temp_file, "This is just plain text.").expect("Write test file"); + + let options = CliOptions { + file: Some(temp_file.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, + }; + + let (findings, _) = run_scan(&options).expect("Scan should succeed"); + assert!(findings.is_empty(), "Should not find secrets"); + + fs::remove_file(temp_file).expect("Cleanup"); +} + +#[test] +fn test_severity_levels() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("keywatch_severity.txt"); + + let content = "\ +AKIAABCDEFGHIJKLMNOP\n\ +password=secret\n\ +user@example.com\n\ +"; + fs::write(&test_file, content).expect("Write test file"); + + let options = CliOptions { + file: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("Scan should succeed"); + assert!( + !findings.is_empty(), + "Should find secrets with different severities" + ); + + let high = findings.iter().filter(|f| f.severity == "HIGH").count(); + let low = findings.iter().filter(|f| f.severity == "LOW").count(); + assert!(high > 0, "Should have HIGH severity findings"); + assert!(low > 0, "Should have LOW severity findings"); + + fs::remove_file(test_file).expect("Cleanup"); +} + +#[test] +fn test_exit_mode_always() { + let temp_file = temp_dir().join("keywatch_exit_always.txt"); + fs::write(&temp_file, "AWS_KEY=AKIAIOSFODNN7EXAMPLE").expect("Write test file"); + + let options = CliOptions { + file: Some(temp_file.to_str().unwrap().to_string()), + dir: None, + output: None, + verbose: false, + allowed_repos: None, + blocked_repos: None, + exclude: None, + install_hook: None, + exit_mode: "always".to_string(), + verify_integrity: false, + }; + + let (findings, _) = run_scan(&options).expect("Scan should succeed"); + assert!(!findings.is_empty(), "Should find secrets"); + + fs::remove_file(temp_file).expect("Cleanup"); +} + +#[test] +fn test_exit_mode_critical_high_vs_low() { + let temp_file = temp_dir().join("keywatch_exit_critical.txt"); + + fs::write(&temp_file, "AKIAABCDEFGHIJKLMNOP").expect("Write HIGH severity"); + + let options_high = CliOptions { + file: Some(temp_file.to_str().unwrap().to_string()), + dir: None, + output: None, + verbose: false, + allowed_repos: None, + blocked_repos: None, + exclude: None, + install_hook: None, + exit_mode: "critical".to_string(), + verify_integrity: false, + }; + + let (findings, _) = run_scan(&options_high).expect("Scan should succeed"); + assert!( + findings.iter().any(|f| f.severity == "HIGH"), + "Should have HIGH severity" + ); + + fs::write(&temp_file, "user@example.com").expect("Write LOW severity"); + + let options_low = CliOptions { + file: Some(temp_file.to_str().unwrap().to_string()), + dir: None, + output: None, + verbose: false, + allowed_repos: None, + blocked_repos: None, + exclude: None, + install_hook: None, + exit_mode: "critical".to_string(), + verify_integrity: false, + }; + + let (findings_low, _) = run_scan(&options_low).expect("Scan should succeed"); + assert!( + findings_low.iter().all(|f| f.severity != "HIGH"), + "Should NOT have HIGH" + ); + + fs::remove_file(temp_file).expect("Cleanup"); +} diff --git a/tests/hooks_tests.rs b/tests/hooks_tests.rs new file mode 100644 index 0000000..33e3691 --- /dev/null +++ b/tests/hooks_tests.rs @@ -0,0 +1,113 @@ +use key_watch::cli::CliOptions; +use key_watch::hooks::{generate_pre_commit_hook, generate_pre_push_hook}; + +#[test] +fn test_hook_generation_pre_commit() { + let options = CliOptions { + file: None, + 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 hook = generate_pre_commit_hook(&options); + assert!(hook.contains("#!/bin/bash"), "Should be bash shebang"); + assert!(hook.contains("key-watch"), "Should reference binary"); + assert!(hook.contains("--exclude"), "Should pass exclude patterns"); +} + +#[test] +fn test_hook_generation_pre_push() { + let options = CliOptions { + file: None, + 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 hook = generate_pre_push_hook(&options); + assert!(hook.contains("#!/bin/bash"), "Should be bash shebang"); + assert!(hook.contains("ALLOWED_REPOS"), "Should set allowed repos"); +} + +#[test] +fn test_hook_shell_escaping() { + let options = CliOptions { + file: None, + 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 hook = generate_pre_push_hook(&options); + assert!( + !hook.contains("test'repos123"), + "Should escape single quotes" + ); +} + +#[test] +fn test_hook_missing_binary_path() { + let options = CliOptions { + file: None, + 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 hook = generate_pre_push_hook(&options); + assert!( + hook.contains("command -v"), + "Hook should verify binary is on PATH" + ); + assert!( + hook.contains("key-watch not found"), + "Hook should report missing binary error" + ); +} + +#[test] +fn test_hook_missing_detectors_toml() { + let options = CliOptions { + file: None, + 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 hook = generate_pre_commit_hook(&options); + assert!( + hook.contains("detectors.toml not found"), + "Hook should check config" + ); +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs deleted file mode 100644 index daa96d9..0000000 --- a/tests/integration_tests.rs +++ /dev/null @@ -1,398 +0,0 @@ -use key_watch::cli::CliOptions; -use key_watch::report::{create_report, ScanMetadata}; -use key_watch::scanner::run_scan; -use key_watch::utils::write_to_file; -use std::env::temp_dir; -use std::fs; - -// -// Test the scanning on a single file that contains multiple secrets. -// It checks that the detectors pick up various patterns (e.g. AWS key, password, and email). -// -#[test] -fn test_find_secrets_in_file() { - // Create a temporary file in the system temp directory. - let temp_dir = temp_dir(); - let test_file = temp_dir.join("key_watch_multiple_secrets.txt"); - - // Create test content with various secret patterns - let content = "\ -// AWS Key -AKIAABCDEFGHIJKLMNOP\n\ -password = 'mySecretPassword'\n\ -email = \"user@example.com\"\n\ -// Firebase API Key -AIzaSyC93k4n4BxvV_XYZ1234567890abcdefghijk\n\ -// SendGrid API Key -SG.abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\n\ -// OpenAI API Key -sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX\n\ -// Discord Token -mfa.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\n\ -// MongoDB Connection String -mongodb://user:password123@mongodb0.example.com:27017\n\ -// Docker Hub Token -dckr_pat_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\n\ -// Square Access Token -sq0atp-abcdefghijklmnopqrstuv\n\ -"; - - fs::write(&test_file, content).expect("Unable to write test file"); - - // Build CLI options to scan the file. - let options = CliOptions { - file: Some(test_file.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - // Run the scan. - let (findings, metadata) = run_scan(&options); - - // Test for specific secret types - let expected_types = vec![ - "Password", - "Email Address", - "AWS Access Key", - "Firebase API Key", - "SendGrid API Key", - "OpenAI API Key", - "Discord Token", - "MongoDB Connection String", - "DockerHub Token", - "Square Access Token", - ]; - - for expected_type in expected_types { - assert!( - findings.iter().any(|f| f.finding_type == expected_type), - "Expected to find a {} but didn't.", - expected_type - ); - } - - assert_eq!(metadata.files_scanned, 1, "Should have scanned 1 file"); - fs::remove_file(&test_file).expect("Unable to remove temporary file"); -} - -#[test] -fn test_find_private_key() { - let temp_dir = temp_dir(); - let test_file = temp_dir.join("key_watch_private_key.txt"); - - let content = "-----BEGIN RSA PRIVATE KEY-----\n\ -MIIEpAIBAAKCAQEA1234567890abcdefghijklmnopqrstuvwxyz\n\ -ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnop\n\ ------END RSA PRIVATE KEY-----"; - - fs::write(&test_file, content).expect("Unable to write test file"); - - let options = CliOptions { - file: Some(test_file.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - let (findings, _) = run_scan(&options); - - assert!( - findings - .iter() - .any(|f| f.finding_type == "Private Key Content"), - "Expected to find a private key" - ); - - fs::remove_file(&test_file).expect("Unable to remove temporary file"); -} - -#[test] -fn test_find_cloud_credentials() { - let temp_dir = temp_dir(); - let test_file = temp_dir.join("key_watch_cloud_creds.txt"); - - let content = "\ -// Azure Storage Account Connection String -DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=abc123def456==\n\ -// Cloudinary URL -cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyz@mycloud\n\ -"; - - fs::write(&test_file, content).expect("Unable to write test file"); - - let options = CliOptions { - file: Some(test_file.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - let (findings, _) = run_scan(&options); - - let expected_types = vec!["Azure Storage Account Key", "Cloudinary URL"]; - - for expected_type in expected_types { - assert!( - findings.iter().any(|f| f.finding_type == expected_type), - "Expected to find {} but didn't.", - expected_type - ); - } - - fs::remove_file(&test_file).expect("Unable to remove temporary file"); -} - -#[test] -fn test_find_api_tokens() { - let temp_dir = temp_dir(); - let test_file = temp_dir.join("key_watch_api_tokens.txt"); - - let content = "\ -// NPM Token -npm_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ\n\ -// CircleCI Token -CIRCLE_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\n\ -// Google OAuth Token -ya29.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\n\ -"; - - fs::write(&test_file, content).expect("Unable to write test file"); - - let options = CliOptions { - file: Some(test_file.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - let (findings, _) = run_scan(&options); - - let expected_types = vec!["NPM Token", "CircleCI Token", "Google OAuth Token"]; - - for expected_type in expected_types { - assert!( - findings.iter().any(|f| f.finding_type == expected_type), - "Expected to find {} but didn't.", - expected_type - ); - } - - fs::remove_file(&test_file).expect("Unable to remove temporary file"); -} - -// -// Test recursive directory scanning. We create a temporary directory with multiple files, -// including a file inside a .git directory (which should be excluded). -// -#[test] -fn test_directory_scan_with_exclusions() { - let base_temp_dir = temp_dir().join("key_watch_temp_dir"); - fs::create_dir_all(&base_temp_dir).expect("Unable to create temporary base directory"); - - // Create two files that should be scanned. - // Updated file1 now contains a valid AWS key that matches: 4 + 16 characters. - let file1_path = base_temp_dir.join("file1.txt"); - let file2_path = base_temp_dir.join("file2.txt"); - - let content1 = "AKIA1234567890123456\nSome harmless text"; - let content2 = "password = 'anotherSecret'\nMore text here"; - - fs::write(&file1_path, content1).expect("Unable to write file1"); - fs::write(&file2_path, content2).expect("Unable to write file2"); - - // Create a subdirectory named .git where files should be excluded. - let excluded_dir = base_temp_dir.join(".git"); - fs::create_dir_all(&excluded_dir).expect("Unable to create .git directory"); - let excluded_file = excluded_dir.join("ignored.txt"); - fs::write(&excluded_file, "This file should be excluded") - .expect("Unable to write excluded file"); - - // Build CLI options: set the directory to scan. - let options = CliOptions { - file: None, - dir: Some(base_temp_dir.to_str().unwrap().to_string()), - output: None, - verbose: false, - }; - - let (findings, metadata) = run_scan(&options); - - // We expect exactly 2 scanned files (excluding those under .git). - assert_eq!( - metadata.files_scanned, 2, - "Should have scanned 2 files (excluding ones in .git)" - ); - // Verify that some files were excluded. - assert!( - !metadata.excluded_files.is_empty(), - "Expected some files to be excluded." - ); - // Check that our expected findings are present. - assert!( - findings - .iter() - .any(|f| f.finding_type.contains("AWS Access Key")), - "Expected to find an AWS Access Key in file1." - ); - assert!( - findings.iter().any(|f| f.finding_type.contains("Password")), - "Expected to find a Password in file2." - ); - - // Cleanup the temporary directory and its contents. - fs::remove_dir_all(&base_temp_dir).expect("Unable to remove temporary directory"); -} - -// -// Test the report creation functionality. The test constructs a report from an empty -// set of findings (which should yield a PASS status) and checks that the JSON has the proper content. -// -#[test] -fn test_create_report() { - // Create an empty vector of findings. - let findings: Vec = Vec::new(); - // Build fake metadata. - let metadata = ScanMetadata { - files_scanned: 1, - total_lines: 10, - excluded_files: vec!["ignored_file.txt".to_string()], - }; - // Create the report. - let report_json = create_report(findings, metadata, "0.1s".to_string()); - // Check that the report indicates PASS when there are no findings. - assert!( - report_json.contains("\"status\": \"PASS\""), - "Report should show PASS status" - ); - // Verify that metadata values are present. - assert!( - report_json.contains("\"files_scanned\": 1"), - "Report should include files_scanned" - ); - assert!( - report_json.contains("\"scan_time\": \"0.1s\""), - "Report should include scan_time" - ); -} - -// -// Test the write_to_file utility function. -// -#[test] -fn test_write_to_file() { - let temp_file_path = temp_dir().join("key_watch_test_output.txt"); - let content = "Temporary content written to file."; - let path_str = temp_file_path.to_str().unwrap(); - - write_to_file(path_str, content).expect("Failed to write to file"); - let read_back = fs::read_to_string(path_str).expect("Failed to read back from file"); - assert_eq!( - read_back, content, - "The written and read content should match" - ); - - // Clean up. - fs::remove_file(temp_file_path).expect("Unable to remove temporary output file"); -} - -// -// Test scanning a file that does not contain any secrets. -// The resulting findings should be empty and the report should indicate a PASS status. -// -#[test] -fn test_scan_no_secrets() { - let temp_file_path = temp_dir().join("key_watch_no_secret.txt"); - let content = "This is a plain text file.\nThere is nothing secret here."; - fs::write(&temp_file_path, content).expect("Unable to write no-secret file"); - - let options = CliOptions { - file: Some(temp_file_path.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - let (findings, _metadata) = run_scan(&options); - assert_eq!( - findings.len(), - 0, - "No findings should be detected in a clean file" - ); - - // Clean up. - fs::remove_file(&temp_file_path).expect("Unable to remove no-secret file"); -} - -// -// Additional test: verify that scanning works with multiple lines and detects multiple occurrences per line. -// -#[test] -fn test_multiple_detections_in_line() { - let temp_file_path = temp_dir().join("key_watch_multiple.txt"); - // This file contains two potential secrets on one line. - let content = "AKIAABCDEFGHIJKLMNOP password = \"superSecret!\""; - fs::write(&temp_file_path, content).expect("Unable to write multiple detection file"); - - let options = CliOptions { - file: Some(temp_file_path.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - let (findings, _metadata) = run_scan(&options); - // We expect at least two findings from the single line. - assert!( - findings.len() >= 2, - "Expected at least two findings (AWS key and Password) but got {}", - findings.len() - ); - // Check that both types are present. - assert!( - findings - .iter() - .any(|f| f.finding_type.contains("AWS Access Key")), - "Expected to find an AWS Access Key in the line." - ); - assert!( - findings.iter().any(|f| f.finding_type.contains("Password")), - "Expected to find a Password in the line." - ); - - // Clean up. - fs::remove_file(&temp_file_path).expect("Unable to remove multiple detection file"); -} - -#[test] -fn test_severity_levels() { - let temp_dir = temp_dir(); - let test_file = temp_dir.join("key_watch_severity.txt"); - - let content = "\ -AKIAABCDEFGHIJKLMNOP\n\ -user@example.com\n\ -"; - - fs::write(&test_file, content).expect("Unable to write test file"); - - let options = CliOptions { - file: Some(test_file.to_str().unwrap().to_string()), - dir: None, - output: None, - verbose: false, - }; - - let (findings, _) = run_scan(&options); - - assert!( - findings.iter().any(|f| f.severity == "HIGH"), - "Expected to find HIGH severity finding" - ); - assert!( - findings.iter().any(|f| f.severity == "LOW"), - "Expected to find LOW severity finding" - ); - - fs::remove_file(&test_file).expect("Unable to remove temporary file"); -} diff --git a/tests/report_tests.rs b/tests/report_tests.rs new file mode 100644 index 0000000..f03c045 --- /dev/null +++ b/tests/report_tests.rs @@ -0,0 +1,53 @@ +use key_watch::report::{ScanMetadata, create_report}; + +#[test] +fn test_create_report() { + let findings = vec![]; + let metadata = ScanMetadata { + files_scanned: 5, + total_lines: 100, + excluded_files: vec![], + }; + + let report = create_report(findings, metadata, "0.5s".to_string()) + .expect("create_report should succeed"); + assert!( + report.contains("\"status\": \"PASS\""), + "Empty findings should be PASS" + ); + assert!( + report.contains("\"files_scanned\": 5"), + "Should include files_scanned" + ); + assert!( + report.contains("\"scan_time\": \"0.5s\""), + "Should include scan_time" + ); +} + +#[test] +fn test_report_with_findings() { + use key_watch::report::Finding; + + let findings = vec![Finding { + file_path: "secret.txt".to_string(), + line_number: 10, + finding_type: "AWS Key".to_string(), + severity: "HIGH".to_string(), + matched_content: "AKIATESTKEY".to_string(), + plugin_name: "AWSKeyDetector".to_string(), + }]; + let metadata = ScanMetadata { + files_scanned: 1, + total_lines: 50, + excluded_files: vec![], + }; + + let report = create_report(findings, metadata, "0.1s".to_string()) + .expect("create_report should succeed"); + assert!( + report.contains("\"status\": \"FAIL\""), + "Should report FAIL status" + ); + assert!(report.contains("AWS Key"), "Should include finding type"); +} diff --git a/tests/scanner_tests.rs b/tests/scanner_tests.rs new file mode 100644 index 0000000..e6ef021 --- /dev/null +++ b/tests/scanner_tests.rs @@ -0,0 +1,296 @@ +use key_watch::cli::CliOptions; +use key_watch::scanner::run_scan; +use std::env::temp_dir; +use std::fs; + +#[test] +fn test_find_secrets_in_file() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("key_watch_multiple_secrets.txt"); + + let content = "\ +AWS Key: AKIAABCDEFGHIJKLMNOP\n\ +password = 'mySecretPassword'\n\ +email = user@example.com\n\ +Firebase: AIzaSyC93k4n4BxvV_XYZ1234567890abcdefghijk\n\ +SG.abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\n\ +sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX\n\ +"; + fs::write(&test_file, content).expect("Unable to write test file"); + + let options = CliOptions { + file: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!(!findings.is_empty(), "Should find secrets"); + + fs::remove_file(test_file).expect("Cleanup"); +} + +#[test] +fn test_find_api_tokens() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("key_watch_api_tokens.txt"); + + let content = "\ +GitHub: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGH\n\ +Slack: xoxb-abcdefghijklmnop-qrstuvwxyz-123456789012\n\ +Stripe: sk_test_51ABCDEF12345678901234567890\n\ +"; + fs::write(&test_file, content).expect("Unable to write test file"); + + let options = CliOptions { + file: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!(!findings.is_empty(), "Should find API tokens"); + + fs::remove_file(test_file).expect("Cleanup"); +} + +#[test] +fn test_find_cloud_credentials() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("key_watch_cloud.txt"); + + let content = "\ +AWS_ACCESS_KEY_ID=AKIAABCDEFGHIJKLMNOP\n\ +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n\ +GCP_API_KEY=AIzaSyC93k4n4BxvV_XYZ1234567890abcdefghijk\n\ +AZURE_STORAGE=DefaultEndpointsProtocol=https;AccountName=examplestore; +"; + fs::write(&test_file, content).expect("Unable to write test file"); + + let options = CliOptions { + file: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!(!findings.is_empty(), "Should find cloud credentials"); + + fs::remove_file(test_file).expect("Cleanup"); +} + +#[test] +fn test_find_private_key() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("key_watch_private_key.txt"); + + let content = "\ +-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCxoe3Fy7N9i+Kj\n\ +-----END RSA PRIVATE KEY-----\n\ +-----BEGIN OPENSSH PRIVATE KEY-----\n\ +b3BlbnNzaC1ldi0xLjAAABgQDQD2FGB3V2t4=\n\ +-----END OPENSSH PRIVATE KEY-----\n\ +"; + fs::write(&test_file, content).expect("Unable to write test file"); + + let options = CliOptions { + file: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!(!findings.is_empty(), "Should find private keys"); + + fs::remove_file(test_file).expect("Cleanup"); +} + +#[test] +fn test_multiple_detections_in_line() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("key_watch_multi.txt"); + + 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: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!( + findings.len() >= 2, + "Should find multiple secrets on one line" + ); + + fs::remove_file(test_file).expect("Cleanup"); +} + +#[test] +fn test_directory_scan_with_exclusions() { + let temp_dir = temp_dir(); + let test_dir = temp_dir.join(format!( + "keywatch_test_dir_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir(&test_dir).expect("Create test directory"); + + fs::write(test_dir.join("secret1.txt"), "AKIATESTKEY123").expect("Write file1"); + fs::write(test_dir.join("secret2.txt"), "password=secret").expect("Write file2"); + 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: None, + dir: Some(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, + }; + + let (findings, metadata) = run_scan(&options).expect("run_scan should succeed"); + assert_eq!( + metadata.files_scanned, 2, + "Should scan 2 files (.git excluded)" + ); + assert!(!findings.is_empty(), "Should find secrets"); + + fs::remove_dir_all(test_dir).expect("Cleanup"); +} + +#[test] +fn test_exclude_pattern_filtering() { + let temp_dir = temp_dir(); + let test_dir = temp_dir.join(format!( + "keywatch_exclude_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir(&test_dir).expect("Create test directory"); + + 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: None, + dir: Some(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, + }; + + let (_findings, metadata) = run_scan(&options).expect("run_scan should succeed"); + assert!( + metadata + .excluded_files + .iter() + .any(|f| f.contains("debug.log")), + "Should exclude *.log" + ); + + fs::remove_dir_all(test_dir).expect("Cleanup"); +} + +#[test] +fn test_scan_no_secrets() { + let temp_file = temp_dir().join("key_watch_no_secret.txt"); + 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: Some(temp_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!(findings.is_empty(), "Should not find secrets in plain text"); + + fs::remove_file(temp_file).expect("Cleanup"); +} + +#[test] +fn test_non_utf8_file_handling() { + let temp_dir = temp_dir(); + let test_file = temp_dir.join("key_watch_binary.bin"); + + 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: Some(test_file.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, + }; + + let (findings, _) = run_scan(&options).expect("run_scan should succeed"); + assert!(findings.is_empty(), "Should gracefully handle binary files"); + + fs::remove_file(test_file).expect("Cleanup"); +} diff --git a/tests/utils_tests.rs b/tests/utils_tests.rs new file mode 100644 index 0000000..2b93b84 --- /dev/null +++ b/tests/utils_tests.rs @@ -0,0 +1,24 @@ +use key_watch::utils::write_to_file; +use std::env::temp_dir; +use std::fs; + +#[test] +fn test_write_to_file() { + let temp_file = temp_dir().join("key_watch_test_output.txt"); + let content = "Temporary content written to file."; + let path_str = temp_file.to_str().unwrap(); + + write_to_file(path_str, content).expect("Failed to write to file"); + let read_back = fs::read_to_string(path_str).expect("Failed to read back"); + assert_eq!(read_back, content, "Content should match"); + + fs::remove_file(temp_file).expect("Cleanup"); +} + +#[test] +fn test_portable_config_loading() { + use key_watch::detector::initialize_detectors; + + let detectors = initialize_detectors().expect("Should load detectors"); + assert!(!detectors.is_empty(), "Should load at least one detector"); +}