From fc90e05ebb5a67b9dde62cf7809625c8b7b6f4da Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 6 Feb 2026 10:01:25 +0000 Subject: [PATCH] Initial commit: env-guard CLI tool with CI/CD --- .env.example | 23 +++ .gitea/workflows/ci.yml | 66 +++++++ .gitignore | 19 ++ Cargo.toml | 38 ++++ LICENSE | 21 +++ README.md | 296 ++++++++++++++++++++++++++++++ src/commands.rs | 367 +++++++++++++++++++++++++++++++++++++ src/config.rs | 102 +++++++++++ src/env_parser.rs | 193 ++++++++++++++++++++ src/framework.rs | 383 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 6 + src/main.rs | 165 +++++++++++++++++ src/secrets.rs | 257 ++++++++++++++++++++++++++ src/validation.rs | 321 ++++++++++++++++++++++++++++++++ tests/cli_test.rs | 66 +++++++ tests/env_parser_test.rs | 96 ++++++++++ tests/secrets_test.rs | 112 ++++++++++++ tests/validation_test.rs | 139 ++++++++++++++ 18 files changed, 2670 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/commands.rs create mode 100644 src/config.rs create mode 100644 src/env_parser.rs create mode 100644 src/framework.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/secrets.rs create mode 100644 src/validation.rs create mode 100644 tests/cli_test.rs create mode 100644 tests/env_parser_test.rs create mode 100644 tests/secrets_test.rs create mode 100644 tests/validation_test.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..381c604 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# This is an auto-generated .env.example file +# Copy this to .env.example and fill in your values + +# Application +APP_NAME=EnvGuard +APP_ENV=development +APP_DEBUG=true + +# Database +DATABASE_URL= + +# Security +SECRET_KEY= + +# API Keys +API_KEY= + +# URLs +API_URL=https://api.example.com + +# Server +PORT=3000 +HOST=localhost diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3a09d50 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: actions/setup-rust@v1 + with: + toolchain: stable + cache: true + + - name: Build + run: cargo build --release + + - name: Run tests + run: cargo test --all + + - name: Run clippy + run: cargo clippy --all --all-features -- -D warnings + + - name: Check formatting + run: cargo fmt -- --check + + binary: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: actions/setup-rust@v1 + with: + toolchain: stable + target: x86_64-unknown-linux-musl + cache: true + + - name: Build binary + run: cargo build --release --target x86_64-unknown-linux-musl + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: env-guard-linux-amd64 + path: target/x86_64-unknown-linux-musl/release/env-guard + + release: + runs-on: ubuntu-latest + needs: binary + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Create Release + uses: https://gitea.com/actions/release-action@main + with: + files: | + target/x86_64-unknown-linux-musl/release/env-guard + title: ${{ github.ref_name }} + body: "Release ${{ github.ref_name }} of env-guard" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e167c92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +target/ + +.vscode/ +.idea/ +*.swp +*.swo + +.DS_Store +Thumbs.db + +.env +.env.local +.env.*.local +*.pem + +test_*.txt +test_*.env + +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1f7d058 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "env-guard" +version = "0.1.0" +edition = "2021" +description = "A Rust CLI tool that automatically detects, validates, and secures environment variables" +authors = ["Env Guard Contributors"] +repository = "https://7000pct.gitea.bloupla.net/7000pctAUTO/env-guard" +license = "MIT" +keywords = ["cli", "env", "security", "validator"] +categories = ["command-line-utilities", "development-tools"] + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +anyhow = "1.0" +confy = "2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +regex = "1.10" +toml = "0.8" +thiserror = "1.0" +dirs = "5.0" + +[[bin]] +name = "env-guard" +path = "src/main.rs" + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.0" + +[profile.release] +strip = true +lto = true +opt-level = 3 + +[features] +default = [] +dev = ["clap/debug"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..215a591 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Env Guard Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d637af --- /dev/null +++ b/README.md @@ -0,0 +1,296 @@ +# Env Guard + +A powerful Rust CLI tool that automatically detects, validates, and secures environment variables across different environments. + +[![CI](https://7000pct.gitea.bloupla.net/7000pctAUTO/env-guard/actions/workflows/ci.yml/badge.svg)](https://7000pct.gitea.bloupla.net/7000pctAUTO/env-guard/actions) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Rust](https://img.shields.io/badge/Rust-1.70+-orange.svg)](https://www.rust-lang.org) + +## Features + +- **Auto-detect missing env vars**: Scan .env files and compare against expected variables defined in schema files +- **Format validation**: Validate URLs, emails, UUIDs, API keys, database connections, JWTs, and more +- **Generate .env.example**: Create template files with descriptive placeholder values +- **Secret detection**: Scan source code for accidentally committed secrets (AWS keys, tokens, passwords) +- **Framework integration**: Auto-detect frameworks (Next.js, Rails, Django, Express, etc.) +- **CI/CD ready**: Perfect for pre-commit hooks and deployment pipelines + +## Installation + +### From Source + +```bash +git clone https://7000pct.gitea.bloupla.net/7000pctAUTO/env-guard.git +cd env-guard +cargo build --release +./target/release/env-guard --help +``` + +### Using Cargo + +```bash +cargo install env-guard +env-guard --help +``` + +### Pre-built Binaries + +Download pre-built binaries from the [Releases](https://7000pct.gitea.bloupla.net/7000pctAUTO/env-guard/releases) page. + +## Quick Start + +```bash +# Check your .env file for issues +env-guard check --path .env + +# Validate all environment variables +env-guard validate --path .env + +# Generate a .env.example template +env-guard generate --path .env --output .env.example + +# Scan for accidentally committed secrets +env-guard secrets --path . + +# Initialize for a specific framework +env-guard init --framework nextjs +``` + +## Commands + +### scan + +Scan .env files and compare against expected variables. + +```bash +env-guard scan --path . --schema .env.schema.json +``` + +Options: +- `-p, --path PATH`: Path to scan for .env files (default: ".") +- `-s, --schema FILE`: Path to schema file + +### validate + +Validate format of environment variable values. + +```bash +env-guard validate --path .env --strict +``` + +Options: +- `-p, --path FILE`: Path to .env file (default: ".env") +- `-S, --strict`: Enable strict validation (fail on any error) + +### generate + +Generate .env.example file from .env. + +```bash +env-guard generate --path .env --output .env.example +``` + +Options: +- `-p, --path FILE`: Path to .env file (default: ".env") +- `-o, --output FILE`: Output file path (default: ".env.example") + +### secrets + +Scan source code for accidentally committed secrets. + +```bash +env-guard secrets --path . --strict +``` + +Options: +- `-p, --path PATH`: Path to scan for secrets (default: ".") +- `-S, --strict`: Enable strict secret detection (fail if any secrets found) + +### init + +Initialize env-guard with framework detection. + +```bash +env-guard init --framework nextjs --path . +``` + +Options: +- `-f, --framework FRAMEWORK`: Framework to use (nextjs, rails, django, node) +- `-p, --path PATH`: Path to project directory (default: ".") + +### check + +Check .env file for common issues. + +```bash +env-guard check --path .env +``` + +Options: +- `-p, --path FILE`: Path to .env file (default: ".env") + +## Framework Support + +Env Guard auto-detects the following frameworks by scanning for configuration files: + +| Framework | Detected By | Key Variables | +|-----------|-------------|---------------| +| Next.js | `next.config.js`, `package.json` | `NEXT_PUBLIC_*`, `NEXTAUTH_*` | +| Ruby on Rails | `Gemfile`, `config.ru` | `DATABASE_URL`, `SECRET_KEY_BASE` | +| Django | `manage.py`, `requirements.txt` | `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` | +| Express.js | `package.json` with "express" | `PORT`, `MONGODB_URI`, `JWT_SECRET` | +| Spring Boot | `pom.xml`, `build.gradle` | `SPRING_DATASOURCE_*`, `SERVER_PORT` | +| Laravel | `composer.json`, `artisan` | `APP_*`, `DB_*` | +| Flask | `app.py`, `requirements.txt` | `FLASK_*`, `SECRET_KEY` | +| NestJS | `package.json` with "@nestjs/core" | `PORT`, `DATABASE_URL`, `JWT_SECRET` | +| Go Fiber | `go.mod` with "gofiber" | `PORT`, `DATABASE_URL` | +| Phoenix | `mix.exs` | `DATABASE_URL`, `SECRET_KEY` | + +## Configuration + +### Schema File (.env.schema.json) + +Define expected environment variables with types and validation: + +```json +{ + "$schema": "https://json.env-guard/schema/v1", + "framework": "nextjs", + "variables": [ + { + "key": "DATABASE_URL", + "required": true, + "type": "database_url", + "description": "PostgreSQL connection string", + "default": "postgresql://localhost:5432/dbname" + }, + { + "key": "API_KEY", + "required": true, + "type": "api_key", + "description": "External API key" + } + ] +} +``` + +### Validation Types + +| Type | Description | Example | +|------|-------------|---------| +| `url` | Valid HTTP/HTTPS URL | `https://api.example.com` | +| `email` | Valid email format | `user@example.com` | +| `uuid` | UUID v4 format | `550e8400-e29b-41d4-a716-446655440000` | +| `api_key` | Generic API key (min 16 chars) | `sk_live_abc123...` | +| `boolean` | true/false, yes/no, 1/0 | `true` | +| `integer` | Whole numbers | `3000` | +| `database_url` | Database connection strings | `postgresql://user:pass@localhost:5432/db` | +| `jwt` | JWT token format | `eyJhbG...` | +| `aws_key` | AWS access key ID | `AKIAIOSFODNN7EXAMPLE` | +| `github_token` | GitHub PAT format | `ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `slack_webhook` | Slack webhook URL | `https://hooks.slack.com/services/T...` | + +## Secret Detection + +Env Guard scans for the following secret patterns: + +- **Critical**: AWS Access Keys, GitHub Tokens, OpenAI Keys, Stripe Keys, Private Keys, JWTs +- **High**: Slack Bot Tokens, Google API Keys +- **Medium**: Slack Webhook URLs + +Example output: +``` +Scanning for secrets in: . + +CRITICAL - 2 found: + [CRITICAL] AWS Access Key ID (line 42): AKIAIOSFODNN7EXAMPLE + -> Rotate this AWS access key immediately and remove from code + [CRITICAL] GitHub Personal Access Token (line 15): ghp_xxxxxxxxxxxxxxxx... + -> Revoke this GitHub token and use a new one + +Total secrets found: 2 +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Env Validation + +on: [push, pull_request] + +jobs: + env-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install env-guard + run: cargo install env-guard + - name: Check for secrets + run: env-guard secrets --path . --strict + - name: Validate env vars + run: env-guard validate --path .env --strict +``` + +## Examples + +### Basic Usage + +```bash +# Generate a .env.example from existing .env +env-guard generate --path .env --output .env.example + +# Validate your .env file +env-guard validate --path .env + +# Check for secrets in your codebase +env-guard secrets --path . --strict + +# Initialize for a new project +env-guard init --framework nextjs + +# Scan for missing required variables +env-guard scan --path . --schema .env.schema.json +``` + +### Strict Mode for CI + +```bash +# Fail CI if secrets are found +env-guard secrets --path . --strict + +# Validate env vars and fail on any error +env-guard validate --path .env --strict +``` + +## Development + +### Building + +```bash +cargo build --release +``` + +### Testing + +```bash +cargo test +cargo clippy +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +## Security + +If you discover a security vulnerability, please open an issue or contact the maintainers directly. We take security seriously and will respond promptly. diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..ff4958b --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,367 @@ +use std::collections::HashMap; +use crate::config::{EnvGuardConfig, SchemaFile}; +use crate::env_parser::EnvFile; +use crate::framework::Framework; +use crate::secrets::{scan_directory, format_secret_match, SecretMatch, SecretSeverity}; +use crate::validation::{Validator, validate_value}; +use std::fs; +use std::path::Path; + +pub fn scan(path: &str, schema_path: Option<&str>) -> Result<(), anyhow::Error> { + println!("Scanning .env files in: {}", path); + + let env_file = EnvFile::from_path(path)?; + + println!("Found {} variables in .env file", env_file.len()); + + if let Some(schema) = schema_path { + if Path::new(schema).exists() { + let schema = SchemaFile::load(schema)?; + let mut missing = Vec::new(); + let mut extra = Vec::new(); + + for schema_var in &schema.variables { + if !env_file.entries.contains_key(&schema_var.key) { + if schema_var.required { + missing.push(&schema_var.key); + } + } + } + + for (key, _) in &env_file.entries { + let found = schema.variables.iter().any(|v| v.key == *key); + if !found { + extra.push(key); + } + } + + if !missing.is_empty() { + println!("\nMissing required variables:"); + for key in &missing { + println!(" - {}", key); + } + } + + if !extra.is_empty() { + println!("\nExtra variables not in schema:"); + for key in &extra { + println!(" - {}", key); + } + } + + if missing.is_empty() && extra.is_empty() { + println!("\nAll schema variables are present and accounted for!"); + } + } + } else { + let framework = Framework::detect(path); + if framework != Framework::Unknown { + println!("\nDetected framework: {}", framework.name()); + let default_schema = framework.get_default_schema(); + let mut missing = Vec::new(); + + for schema_var in &default_schema { + if !env_file.entries.contains_key(&schema_var.key) { + if schema_var.required { + missing.push(&schema_var.key); + } + } + } + + if !missing.is_empty() { + println!("\nMissing required variables for {}:", framework.name()); + for key in &missing { + println!(" - {}", key); + } + } + } + } + + Ok(()) +} + +pub fn validate(path: &str, strict: bool) -> Result<(), anyhow::Error> { + println!("Validating .env file: {}", path); + + if !Path::new(path).exists() { + return Err(anyhow::anyhow!("File not found: {}", path)); + } + + let content = fs::read_to_string(path)?; + let env_file = EnvFile::parse(&content)?; + + let _validator = Validator::with_builtin_rules(); + let mut errors: Vec = Vec::new(); + let warnings: Vec = Vec::new(); + + for (key, entry) in &env_file.entries { + let value = entry.unquoted_value(); + + if value.trim().is_empty() { + if strict { + errors.push(format!("Empty value for required variable: {}", key)); + } + continue; + } + + if let Err(e) = validate_value(key, &value, None) { + errors.push(format!("Validation error for {}: {}", key, e)); + } + } + + if !errors.is_empty() { + println!("\nValidation Errors:"); + for error in &errors { + println!(" - {}", error); + } + } else { + println!("\nValidation passed!"); + } + + if !warnings.is_empty() { + println!("\nWarnings:"); + for warning in &warnings { + println!(" ! {}", warning); + } + } + + if !errors.is_empty() && strict { + return Err(anyhow::anyhow!("Validation failed with {} errors", errors.len())); + } + + Ok(()) +} + +pub fn generate(path: &str, output: Option<&str>) -> Result<(), anyhow::Error> { + println!("Generating .env.example from: {}", path); + + if !Path::new(path).exists() { + return Err(anyhow::anyhow!("File not found: {}", path)); + } + + let content = fs::read_to_string(path)?; + let env_file = EnvFile::parse(&content)?; + let output_path = output.unwrap_or(".env.example"); + + let mut output_content = String::new(); + output_content.push_str("# This is an auto-generated .env.example file\n"); + output_content.push_str("# Copy this to .env.example and fill in your values\n\n"); + + let framework = Framework::detect(path); + let schema = if framework != Framework::Unknown { + framework.get_default_schema() + } else { + Vec::new() + }; + + for key in env_file.entries.keys() { + let placeholder = get_placeholder_for_key(key, &schema); + + output_content.push_str(&format!("# {}={}\n", key, placeholder)); + } + + fs::write(output_path, output_content)?; + println!("Generated .env.example at: {}", output_path); + + Ok(()) +} + +fn get_placeholder_for_key(key: &str, schema: &[crate::config::EnvVarSchema]) -> String { + let key_lower = key.to_lowercase(); + + if let Some(schema_var) = schema.iter().find(|v| v.key == key) { + if let Some(default) = &schema_var.default { + return default.clone(); + } + if let Some(desc) = &schema_var.description { + return format!("<{}>", desc.to_lowercase().replace(' ', "_")); + } + } + + if key_lower.contains("url") || key_lower.contains("uri") { + return "https://example.com/api".to_string(); + } + + if key_lower.contains("email") { + return "user@example.com".to_string(); + } + + if key_lower.contains("secret") || key_lower.contains("key") || key_lower.contains("token") { + return "".to_string(); + } + + if key_lower.contains("password") || key_lower.contains("pwd") { + return "".to_string(); + } + + if key_lower.contains("database") || key_lower.contains("db_") { + return "postgresql://user:password@localhost:5432/dbname".to_string(); + } + + if key_lower.contains("redis") { + return "redis://localhost:6379".to_string(); + } + + if key_lower.contains("port") { + return "3000".to_string(); + } + + if key_lower.contains("host") { + return "localhost".to_string(); + } + + if key_lower.contains("debug") || key_lower.contains("enabled") { + return "true".to_string(); + } + + if key_lower.contains("aws") && key_lower.contains("key") { + return "".to_string(); + } + + if key_lower.contains("aws") && key_lower.contains("secret") { + return "".to_string(); + } + + "".to_string() +} + +pub fn secrets_cmd(path: &str, strict: bool) -> Result<(), anyhow::Error> { + println!("Scanning for secrets in: {}", path); + + let matches = scan_directory(path, strict, None)?; + + if matches.is_empty() { + println!("\nNo secrets found!"); + return Ok(()); + } + + let mut by_severity: HashMap> = HashMap::new(); + for m in &matches { + by_severity.entry(m.severity.clone()).or_default().push(m.clone()); + } + + let order = [SecretSeverity::Critical, SecretSeverity::High, SecretSeverity::Medium, SecretSeverity::Low]; + let mut total_found = 0; + + for severity in order { + if let Some(matches) = by_severity.get(&severity) { + let count = matches.len(); + println!("\n{} - {} found:", severity.as_str(), count); + for m in matches { + println!(" {}", format_secret_match(m)); + total_found += 1; + } + } + } + + println!("\nTotal secrets found: {}", total_found); + + if strict { + return Err(anyhow::anyhow!("Secrets found in code!")); + } + + Ok(()) +} + +pub fn init(framework: Option<&str>, path: Option<&str>) -> Result<(), anyhow::Error> { + let scan_path = path.unwrap_or("."); + let detected = Framework::detect(scan_path); + let framework_name = framework.unwrap_or_else(|| { + if detected != Framework::Unknown { + detected.name() + } else { + "custom" + } + }); + + println!("Initializing env-guard configuration..."); + println!("Framework: {}", framework_name); + + let _config = EnvGuardConfig::new()?; + let mut schema = SchemaFile::default(); + + if framework_name != "custom" { + let detected_framework = Framework::detect(scan_path); + if detected_framework != Framework::Unknown { + schema.variables = detected_framework.get_default_schema(); + println!("Using default schema for {}", detected_framework.name()); + } else { + schema.variables = Vec::new(); + } + } else { + schema.variables = Vec::new(); + } + + let schema_path = ".env.schema.json"; + schema.save(schema_path)?; + + println!("Created schema file at: {}", schema_path); + println!("Variables defined: {}", schema.variables.len()); + + Ok(()) +} + +pub fn check(path: &str) -> Result<(), anyhow::Error> { + println!("Checking .env file: {}", path); + + if !Path::new(path).exists() { + return Err(anyhow::anyhow!("File not found: {}", path)); + } + + let content = fs::read_to_string(path)?; + let env_file = EnvFile::parse(&content)?; + + println!("\nSummary:"); + println!(" Total variables: {}", env_file.len()); + + let mut has_secrets = Vec::new(); + let mut has_urls = Vec::new(); + let mut has_databases = Vec::new(); + + for (key, entry) in &env_file.entries { + let value = entry.unquoted_value(); + + if key.to_lowercase().contains("secret") + || key.to_lowercase().contains("password") + || key.to_lowercase().contains("key") + { + has_secrets.push(key.clone()); + } + + if value.contains("http://") || value.contains("https://") { + has_urls.push(key.clone()); + } + + if value.contains("postgresql://") + || value.contains("mysql://") + || value.contains("mongodb://") + { + has_databases.push(key.clone()); + } + } + + println!(" Secrets/API keys: {}", has_secrets.len()); + println!(" URLs: {}", has_urls.len()); + println!(" Database connections: {}", has_databases.len()); + + if !has_secrets.is_empty() { + println!("\nVariables containing secrets:"); + for key in &has_secrets { + println!(" - {}", key); + } + } + + if !has_databases.is_empty() { + println!("\nDatabase connection variables:"); + for key in &has_databases { + println!(" - {}", key); + } + } + + let framework = Framework::detect("."); + if framework != Framework::Unknown { + println!("\nDetected framework: {}", framework.name()); + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dd3b638 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use thiserror::Error; +use anyhow::Result; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("Configuration file error: {0}")] + FileError(String), + #[error("Parse error: {0}")] + ParseError(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EnvVarSchema { + pub key: String, + pub required: bool, + pub r#type: Option, + pub description: Option, + pub pattern: Option, + pub default: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProjectConfig { + pub name: Option, + pub framework: Option, + pub variables: Vec, + pub ignore_patterns: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EnvGuardConfig { + pub projects: HashMap, + pub global_ignore: Vec, +} + +impl EnvGuardConfig { + pub fn new() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; + let config_path = config_dir.join("env-guard").join("config.toml"); + + if config_path.exists() { + let content = fs::read_to_string(&config_path)?; + let config: EnvGuardConfig = toml::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?; + Ok(config) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self) -> Result<()> { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; + let config_path = config_dir.join("env-guard"); + + if !config_path.exists() { + fs::create_dir_all(&config_path)?; + } + + let config_file = config_path.join("config.toml"); + let content = toml::to_string(self) + .map_err(|e| anyhow::anyhow!("Failed to serialize config: {}", e))?; + fs::write(&config_file, content)?; + Ok(()) + } + + pub fn get_project_config(&self, project: &str) -> Option<&ProjectConfig> { + self.projects.get(project) + } + + pub fn add_project(&mut self, name: String, config: ProjectConfig) { + self.projects.insert(name, config); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SchemaFile { + #[serde(rename = "$schema")] + pub schema_version: Option, + pub framework: Option, + pub variables: Vec, +} + +impl SchemaFile { + pub fn load(path: &str) -> Result { + let content = fs::read_to_string(path)?; + let schema: SchemaFile = serde_json::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse schema file: {}", e))?; + Ok(schema) + } + + pub fn save(&self, path: &str) -> Result<()> { + let content = serde_json::to_string_pretty(self) + .map_err(|e| anyhow::anyhow!("Failed to serialize schema: {}", e))?; + fs::write(path, content)?; + Ok(()) + } +} diff --git a/src/env_parser.rs b/src/env_parser.rs new file mode 100644 index 0000000..7144a01 --- /dev/null +++ b/src/env_parser.rs @@ -0,0 +1,193 @@ +use regex::Regex; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use thiserror::Error; +use anyhow::Result; + +#[derive(Debug, Error)] +pub enum EnvParseError { + #[error("File not found: {0}")] + FileNotFound(String), + #[error("Invalid line format: {0}")] + InvalidLine(String), + #[error("Parse error: {0}")] + ParseError(String), +} + +#[derive(Debug, Clone, Default)] +pub struct EnvEntry { + pub key: String, + pub value: String, + pub comment: Option, + pub line_number: usize, + pub is_quoted: bool, + pub is_multiline: bool, +} + +impl EnvEntry { + pub fn new(key: String, value: String, line_number: usize) -> Self { + let is_quoted = (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')); + Self { + key, + value, + comment: None, + line_number, + is_quoted, + is_multiline: false, + } + } + + pub fn unquoted_value(&self) -> String { + let val = self.value.trim(); + if self.is_quoted { + val[1..val.len()-1].to_string() + } else { + val.to_string() + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct EnvFile { + pub entries: HashMap, + pub raw_lines: Vec, + pub comments: Vec, +} + +#[derive(Debug, Clone)] +pub struct CommentLine { + pub text: String, + pub line_number: usize, +} + +impl EnvFile { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + raw_lines: Vec::new(), + comments: Vec::new(), + } + } + + pub fn from_path(path: &str) -> Result { + if !Path::new(path).exists() { + return Err(anyhow::anyhow!(".env file not found: {}", path)); + } + let content = fs::read_to_string(path)?; + Self::parse(&content) + } + + pub fn parse(content: &str) -> Result { + let mut env_file = Self::new(); + let mut current_key: Option = None; + let mut multiline_value = String::new(); + let re = Regex::new(r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$"#)?; + + for (line_number, line) in content.lines().enumerate() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + env_file.raw_lines.push(line.to_string()); + continue; + } + + if trimmed.starts_with('#') { + env_file.comments.push(CommentLine { + text: trimmed.to_string(), + line_number, + }); + env_file.raw_lines.push(line.to_string()); + continue; + } + + if let Some(caps) = re.captures(trimmed) { + let key = caps[1].to_string(); + let value = caps[2].to_string(); + + if value.ends_with('\\') && !value.ends_with("\\\\") { + multiline_value = value.trim_end_matches('\\').to_string() + "\n"; + current_key = Some(key); + } else { + if let Some(prev_key) = current_key.take() { + multiline_value.push_str(&value); + let mut entry = EnvEntry::new(prev_key.clone(), multiline_value.clone(), line_number); + entry.is_multiline = true; + env_file.entries.insert(prev_key, entry); + multiline_value.clear(); + } else { + let entry = EnvEntry::new(key.clone(), value.clone(), line_number); + env_file.entries.insert(key, entry); + } + } + } else if current_key.is_some() { + multiline_value.push_str(line); + multiline_value.push('\n'); + } else { + env_file.raw_lines.push(line.to_string()); + } + } + + if let Some(prev_key) = current_key.take() { + let mut entry = EnvEntry::new(prev_key.clone(), multiline_value.clone(), 0); + entry.is_multiline = true; + env_file.entries.insert(prev_key, entry); + } + + Ok(env_file) + } + + pub fn get(&self, key: &str) -> Option<&EnvEntry> { + self.entries.get(key) + } + + pub fn keys(&self) -> Vec<&String> { + self.entries.keys().collect() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn to_string(&self) -> String { + self.raw_lines.join("\n") + } + + pub fn write_to_file(&self, path: &str) -> Result<()> { + let mut output = Vec::new(); + + for (key, entry) in &self.entries { + let line = format!("{}={}", key, entry.value); + output.push(line); + } + + for comment in &self.comments { + output.insert(comment.line_number, comment.text.clone()); + } + + fs::write(path, output.join("\n"))?; + Ok(()) + } +} + +pub fn parse_dotenv(content: &str) -> Result> { + let env_file = EnvFile::parse(content)?; + let mut result = HashMap::new(); + for (key, entry) in &env_file.entries { + result.insert(key.clone(), entry.unquoted_value()); + } + Ok(result) +} + +pub fn extract_key_value(line: &str) -> Option<(String, String)> { + let re = Regex::new(r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$"#).ok()?; + let caps = re.captures(line)?; + let key = caps.get(1)?.as_str().to_string(); + let value = caps.get(2)?.as_str().to_string(); + Some((key, value)) +} diff --git a/src/framework.rs b/src/framework.rs new file mode 100644 index 0000000..7f84599 --- /dev/null +++ b/src/framework.rs @@ -0,0 +1,383 @@ +use std::fs; +use std::path::Path; +use crate::config::EnvVarSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Framework { + NextJs, + Rails, + Django, + Express, + SpringBoot, + Laravel, + Flask, + ASPNET, + NestJS, + GoFiber, + Phoenix, + Unknown, +} + +impl Framework { + pub fn detect(path: &str) -> Self { + let path = Path::new(path); + + if path.join("next.config.js").exists() || path.join("next.config.mjs").exists() { + return Framework::NextJs; + } + + if path.join("Gemfile").exists() && path.join("config.ru").exists() { + return Framework::Rails; + } + + if path.join("manage.py").exists() && path.join("requirements.txt").exists() { + return Framework::Django; + } + + if path.join("package.json").exists() { + let pkg_json = fs::read_to_string(path.join("package.json").to_string_lossy().as_ref()); + if let Ok(content) = pkg_json { + if content.contains("\"next\"") || content.contains("\"next\":") { + return Framework::NextJs; + } + if content.contains("\"express\"") { + return Framework::Express; + } + if content.contains("\"@nestjs/core\"") { + return Framework::NestJS; + } + } + } + + if path.join("composer.json").exists() && path.join("artisan").exists() { + return Framework::Laravel; + } + + if path.join("pom.xml").exists() || path.join("build.gradle").exists() { + return Framework::SpringBoot; + } + + if path.join("app.rb").exists() && path.join("config.ru").exists() { + return Framework::Rails; + } + + if path.join("app.py").exists() && (path.join("requirements.txt").exists() || path.join("pyproject.toml").exists()) { + return Framework::Flask; + } + + if path.join("go.mod").exists() { + let go_mod = fs::read_to_string(path.join("go.mod").to_string_lossy().as_ref()); + if let Ok(content) = go_mod { + if content.contains("gofiber") || content.contains("fiber") { + return Framework::GoFiber; + } + } + } + + if path.join("mix.exs").exists() { + return Framework::Phoenix; + } + + Framework::Unknown + } + + pub fn name(&self) -> &str { + match self { + Framework::NextJs => "Next.js", + Framework::Rails => "Ruby on Rails", + Framework::Django => "Django", + Framework::Express => "Express.js", + Framework::SpringBoot => "Spring Boot", + Framework::Laravel => "Laravel", + Framework::Flask => "Flask", + Framework::ASPNET => "ASP.NET", + Framework::NestJS => "NestJS", + Framework::GoFiber => "Go Fiber", + Framework::Phoenix => "Phoenix", + Framework::Unknown => "Unknown", + } + } + + pub fn get_default_schema(&self) -> Vec { + match self { + Framework::NextJs => vec![ + EnvVarSchema { + key: "NEXT_PUBLIC_API_URL".to_string(), + required: false, + r#type: Some("url".to_string()), + description: Some("Public API endpoint for client-side code".to_string()), + pattern: None, + default: Some("http://localhost:3000/api".to_string()), + }, + EnvVarSchema { + key: "NEXTAUTH_SECRET".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Secret for NextAuth.js".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "NEXTAUTH_URL".to_string(), + required: true, + r#type: Some("url".to_string()), + description: Some("URL for NextAuth.js".to_string()), + pattern: None, + default: Some("http://localhost:3000".to_string()), + }, + EnvVarSchema { + key: "DATABASE_URL".to_string(), + required: true, + r#type: Some("database_url".to_string()), + description: Some("PostgreSQL connection string".to_string()), + pattern: None, + default: None, + }, + ], + Framework::Rails => vec![ + EnvVarSchema { + key: "DATABASE_URL".to_string(), + required: true, + r#type: Some("database_url".to_string()), + description: Some("PostgreSQL/MySQL connection string".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "SECRET_KEY_BASE".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Secret key for Rails".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "RAILS_ENV".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Rails environment (development, production, test)".to_string()), + pattern: None, + default: Some("development".to_string()), + }, + EnvVarSchema { + key: "REDIS_URL".to_string(), + required: false, + r#type: Some("url".to_string()), + description: Some("Redis connection URL".to_string()), + pattern: None, + default: None, + }, + ], + Framework::Django => vec![ + EnvVarSchema { + key: "SECRET_KEY".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Django secret key".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "DEBUG".to_string(), + required: false, + r#type: Some("boolean".to_string()), + description: Some("Enable debug mode".to_string()), + pattern: None, + default: Some("False".to_string()), + }, + EnvVarSchema { + key: "ALLOWED_HOSTS".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Comma-separated allowed hosts".to_string()), + pattern: None, + default: Some("localhost,127.0.0.1".to_string()), + }, + EnvVarSchema { + key: "DATABASE_URL".to_string(), + required: true, + r#type: Some("database_url".to_string()), + description: Some("PostgreSQL connection string".to_string()), + pattern: None, + default: None, + }, + ], + Framework::Express => vec![ + EnvVarSchema { + key: "PORT".to_string(), + required: false, + r#type: Some("integer".to_string()), + description: Some("Port number".to_string()), + pattern: None, + default: Some("3000".to_string()), + }, + EnvVarSchema { + key: "MONGODB_URI".to_string(), + required: false, + r#type: Some("url".to_string()), + description: Some("MongoDB connection string".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "JWT_SECRET".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("JWT signing secret".to_string()), + pattern: None, + default: None, + }, + ], + Framework::SpringBoot => vec![ + EnvVarSchema { + key: "SPRING_DATASOURCE_URL".to_string(), + required: true, + r#type: Some("database_url".to_string()), + description: Some("Database connection URL".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "SPRING_DATASOURCE_USERNAME".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Database username".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "SPRING_DATASOURCE_PASSWORD".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Database password".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "SERVER_PORT".to_string(), + required: false, + r#type: Some("integer".to_string()), + description: Some("Server port".to_string()), + pattern: None, + default: Some("8080".to_string()), + }, + ], + Framework::Laravel => vec![ + EnvVarSchema { + key: "APP_NAME".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Application name".to_string()), + pattern: None, + default: Some("Laravel".to_string()), + }, + EnvVarSchema { + key: "APP_ENV".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Environment (local, production, etc.)".to_string()), + pattern: None, + default: Some("production".to_string()), + }, + EnvVarSchema { + key: "APP_KEY".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Application encryption key".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "DB_CONNECTION".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Database driver (mysql, pgsql, sqlite)".to_string()), + pattern: None, + default: Some("mysql".to_string()), + }, + ], + Framework::Flask => vec![ + EnvVarSchema { + key: "FLASK_APP".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Flask application module".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "FLASK_ENV".to_string(), + required: false, + r#type: Some("string".to_string()), + description: Some("Flask environment".to_string()), + pattern: None, + default: Some("development".to_string()), + }, + EnvVarSchema { + key: "SECRET_KEY".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("Secret key for sessions".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "DATABASE_URL".to_string(), + required: false, + r#type: Some("database_url".to_string()), + description: Some("Database connection URL".to_string()), + pattern: None, + default: None, + }, + ], + Framework::NestJS => vec![ + EnvVarSchema { + key: "PORT".to_string(), + required: false, + r#type: Some("integer".to_string()), + description: Some("Port number".to_string()), + pattern: None, + default: Some("3000".to_string()), + }, + EnvVarSchema { + key: "DATABASE_URL".to_string(), + required: true, + r#type: Some("database_url".to_string()), + description: Some("PostgreSQL connection string".to_string()), + pattern: None, + default: None, + }, + EnvVarSchema { + key: "JWT_SECRET".to_string(), + required: true, + r#type: Some("string".to_string()), + description: Some("JWT secret key".to_string()), + pattern: None, + default: None, + }, + ], + _ => Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FrameworkConfig { + pub name: String, + pub version: String, + pub detected: bool, + pub variables: Vec, +} + +pub fn detect_framework(path: &str) -> FrameworkConfig { + let framework = Framework::detect(path); + let variables = framework.get_default_schema(); + + FrameworkConfig { + name: framework.name().to_string(), + version: String::new(), + detected: framework != Framework::Unknown, + variables, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cde5a9d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod env_parser; +pub mod validation; +pub mod secrets; +pub mod framework; +pub mod commands; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5cf4aa0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,165 @@ +use clap::{Command, Arg}; +use anyhow::Result; + +mod config; +mod env_parser; +mod validation; +mod secrets; +mod framework; +mod commands; + +use commands::{scan, validate, generate, secrets_cmd, init, check}; + +fn main() -> Result<()> { + let matches = Command::new("env-guard") + .version("0.1.0") + .about("Automatically detect, validate, and secure environment variables") + .subcommand_required(false) + .arg_required_else_help(true) + .subcommand( + Command::new("scan") + .about("Scan .env files and compare against expected variables") + .arg( + Arg::new("path") + .short('p') + .long("path") + .value_name("PATH") + .help("Path to scan for .env files") + .default_value(".") + ) + .arg( + Arg::new("schema") + .short('s') + .long("schema") + .value_name("FILE") + .help("Path to schema file (.env.schema.json)") + ) + ) + .subcommand( + Command::new("validate") + .about("Validate format of environment variable values") + .arg( + Arg::new("path") + .short('p') + .long("path") + .value_name("FILE") + .help("Path to .env file") + .default_value(".env") + ) + .arg( + Arg::new("strict") + .short('S') + .long("strict") + .help("Enable strict validation") + .action(clap::ArgAction::SetTrue) + ) + ) + .subcommand( + Command::new("generate") + .about("Generate .env.example file from .env") + .arg( + Arg::new("path") + .short('p') + .long("path") + .value_name("FILE") + .help("Path to .env file") + .default_value(".env") + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .value_name("FILE") + .help("Output file path") + .default_value(".env.example") + ) + ) + .subcommand( + Command::new("secrets") + .about("Scan source code for accidentally committed secrets") + .arg( + Arg::new("path") + .short('p') + .long("path") + .value_name("PATH") + .help("Path to scan for secrets") + .default_value(".") + ) + .arg( + Arg::new("strict") + .short('S') + .long("strict") + .help("Enable strict secret detection") + .action(clap::ArgAction::SetTrue) + ) + ) + .subcommand( + Command::new("init") + .about("Initialize env-guard with framework detection") + .arg( + Arg::new("framework") + .short('f') + .long("framework") + .value_name("FRAMEWORK") + .help("Framework to use (nextjs, rails, django, node)") + ) + .arg( + Arg::new("path") + .short('p') + .long("path") + .value_name("PATH") + .help("Path to project directory") + .default_value(".") + ) + ) + .subcommand( + Command::new("check") + .about("Check .env file for common issues") + .arg( + Arg::new("path") + .short('p') + .long("path") + .value_name("FILE") + .help("Path to .env file") + .default_value(".env") + ) + ) + .get_matches(); + + match matches.subcommand() { + Some(("scan", sub_matches)) => { + let path = sub_matches.get_one::("path").map(|s| s.as_str()).unwrap_or("."); + let schema = sub_matches.get_one::("schema").map(|s| s.as_str()); + scan(path, schema)?; + } + Some(("validate", sub_matches)) => { + let path = sub_matches.get_one::("path").map(|s| s.as_str()).unwrap_or(".env"); + let strict = sub_matches.get_flag("strict"); + validate(path, strict)?; + } + Some(("generate", sub_matches)) => { + let path = sub_matches.get_one::("path").map(|s| s.as_str()).unwrap_or(".env"); + let output = sub_matches.get_one::("output").map(|s| s.as_str()); + generate(path, output)?; + } + Some(("secrets", sub_matches)) => { + let path = sub_matches.get_one::("path").map(|s| s.as_str()).unwrap_or("."); + let strict = sub_matches.get_flag("strict"); + secrets_cmd(path, strict)?; + } + Some(("init", sub_matches)) => { + let framework = sub_matches.get_one::("framework").map(|s| s.as_str()); + let path = sub_matches.get_one::("path").map(|s| s.as_str()); + init(framework, path)?; + } + Some(("check", sub_matches)) => { + let path = sub_matches.get_one::("path").map(|s| s.as_str()).unwrap_or(".env"); + check(path)?; + } + _ => { + let _ = Command::new("env-guard").print_help(); + } + } + + Ok(()) +} diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..a77edf3 --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,257 @@ +use regex::Regex; +use std::fs; +use std::path::Path; +use thiserror::Error; +use anyhow::Result; + +#[derive(Debug, Error)] +pub enum SecretError { + #[error("Secret found: {0}")] + SecretFound(String), + #[error("Scan error: {0}")] + ScanError(String), +} + +#[derive(Debug, Clone)] +pub struct SecretMatch { + pub file: String, + pub line_number: usize, + pub line_content: String, + pub secret_type: String, + pub severity: SecretSeverity, + pub recommendation: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SecretSeverity { + Critical, + High, + Medium, + Low, +} + +impl SecretSeverity { + pub fn as_str(&self) -> &str { + match self { + SecretSeverity::Critical => "CRITICAL", + SecretSeverity::High => "HIGH", + SecretSeverity::Medium => "MEDIUM", + SecretSeverity::Low => "LOW", + } + } +} + +#[derive(Debug, Clone)] +pub struct SecretPattern { + pub name: String, + pub pattern: Regex, + pub severity: SecretSeverity, + pub recommendation: String, + pub example: String, +} + +pub fn get_builtin_patterns() -> Vec { + vec![ + SecretPattern { + name: "AWS Access Key ID".to_string(), + pattern: Regex::new(r"(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Rotate this AWS access key immediately and remove from code".to_string(), + example: "AKIAIOSFODNN7EXAMPLE".to_string(), + }, + SecretPattern { + name: "AWS Secret Access Key".to_string(), + pattern: Regex::new(r"(?i)aws_secret_access_key\s*=\s*[\w/+]{40}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Rotate this AWS secret key and use environment variables".to_string(), + example: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), + }, + SecretPattern { + name: "GitHub Personal Access Token".to_string(), + pattern: Regex::new(r"ghp_[A-Za-z0-9_]{36,}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Revoke this GitHub token and use a new one".to_string(), + example: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + }, + SecretPattern { + name: "GitHub OAuth Token".to_string(), + pattern: Regex::new(r"gho_[A-Za-z0-9_]{36,}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Revoke this GitHub OAuth token".to_string(), + example: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + }, + SecretPattern { + name: "GitHub App Token".to_string(), + pattern: Regex::new(r"(ghu|ghs)_[A-Za-z0-9_]{36,}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Revoke this GitHub app token".to_string(), + example: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + }, + SecretPattern { + name: "Slack Bot Token".to_string(), + pattern: Regex::new(r"xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}").unwrap(), + severity: SecretSeverity::High, + recommendation: "Rotate this Slack bot token".to_string(), + example: "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx".to_string(), + }, + SecretPattern { + name: "Slack Webhook URL".to_string(), + pattern: Regex::new(r"https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+").unwrap(), + severity: SecretSeverity::Medium, + recommendation: "Consider rotating if sensitive information was shared".to_string(), + example: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX".to_string(), + }, + SecretPattern { + name: "Private Key".to_string(), + pattern: Regex::new(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Remove private key from code and use external storage".to_string(), + example: "-----BEGIN RSA PRIVATE KEY-----".to_string(), + }, + SecretPattern { + name: "JWT Token".to_string(), + pattern: Regex::new(r"eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*").unwrap(), + severity: SecretSeverity::High, + recommendation: "Check if this JWT contains sensitive information".to_string(), + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(), + }, + SecretPattern { + name: "OpenAI API Key".to_string(), + pattern: Regex::new(r"sk-[A-Za-z0-9]{48}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Rotate this OpenAI API key immediately".to_string(), + example: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + }, + SecretPattern { + name: "Stripe API Key".to_string(), + pattern: Regex::new(r"(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{24,}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Rotate this Stripe API key and use test keys in development".to_string(), + example: "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + }, + SecretPattern { + name: "Heroku API Key".to_string(), + pattern: Regex::new(r"(?i)heroku[_-]api[_-]key\s*[:=]\s*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}").unwrap(), + severity: SecretSeverity::Critical, + recommendation: "Revoke this Heroku API key".to_string(), + example: "HEROKU_API_KEY = 01234567-89ab-cdef-0123-456789abcdef".to_string(), + }, + SecretPattern { + name: "Google API Key".to_string(), + pattern: Regex::new(r"AIza[0-9A-Za-z\\-_]{35}").unwrap(), + severity: SecretSeverity::High, + recommendation: "Restrict this Google API key to your domain".to_string(), + example: "AIzaSyDaC4eD6KdXy3XyZEx4XlX3XZ3XlX3XZ3X".to_string(), + }, + ] +} + +pub fn scan_file(file_path: &str, strict: bool) -> Result> { + let content = fs::read_to_string(file_path)?; + let patterns = get_builtin_patterns(); + let mut matches = Vec::new(); + + for (line_number, line) in content.lines().enumerate() { + for pattern in &patterns { + if pattern.pattern.is_match(line) { + if strict || pattern.severity != SecretSeverity::Low { + matches.push(SecretMatch { + file: file_path.to_string(), + line_number: line_number + 1, + line_content: line.to_string(), + secret_type: pattern.name.clone(), + severity: pattern.severity.clone(), + recommendation: pattern.recommendation.clone(), + }); + } + } + } + } + + Ok(matches) +} + +pub fn scan_directory(path: &str, strict: bool, ignore_patterns: Option<&[String]>) -> Result> { + let mut all_matches = Vec::new(); + let patterns = get_builtin_patterns(); + let ignore_set: Vec = ignore_patterns + .map(|v| v.iter().map(|s| s.to_lowercase()).collect()) + .unwrap_or_default(); + + fn is_ignored(path: &str, ignore_patterns: &[String]) -> bool { + let path_lower = path.to_lowercase(); + for pattern in ignore_patterns { + if path_lower.contains(&pattern.to_lowercase()) { + return true; + } + } + false + } + + fn scan_dir_recursive( + dir: &Path, + patterns: &[SecretPattern], + all_matches: &mut Vec, + strict: bool, + ignore_patterns: &[String], + ) -> Result<()> { + if is_ignored(&dir.to_string_lossy(), ignore_patterns) { + return Ok(()); + } + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if is_ignored(&path.to_string_lossy(), ignore_patterns) { + continue; + } + + if path.is_dir() { + if path.file_name().map(|n| n.to_string_lossy()) != Some("node_modules".into()) + && path.file_name().map(|n| n.to_string_lossy()) != Some(".git".into()) + { + scan_dir_recursive(&path, patterns, all_matches, strict, ignore_patterns)?; + } + } else if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + let scanable = [ + "js", "ts", "py", "rb", "go", "java", "c", "cpp", "h", "rs", + "php", "swift", "kt", "scala", "r", "sql", "json", "yaml", "yml", + "xml", "env", "ini", "cfg", "conf", "txt", "md", + ]; + + if scanable.contains(&ext_str.as_str()) || path.file_name().map(|n| n.to_string_lossy()) == Some(".env".into()) { + if let Ok(file_matches) = scan_file(&path.to_string_lossy(), strict) { + all_matches.extend(file_matches); + } + } + } + } + + Ok(()) + } + + scan_dir_recursive(Path::new(path), &patterns, &mut all_matches, strict, &ignore_set)?; + Ok(all_matches) +} + +pub fn redact_secret(value: &str) -> String { + if value.len() <= 8 { + return "*".repeat(value.len()); + } + let visible = 4; + let hidden = value.len() - visible; + format!("{}{}", &value[..visible], "*".repeat(hidden.min(40))) +} + +pub fn format_secret_match(match_item: &SecretMatch) -> String { + format!( + "[{}] {} (line {}): {}\n -> {}", + match_item.severity.as_str(), + match_item.secret_type, + match_item.line_number, + redact_secret(&match_item.line_content), + match_item.recommendation + ) +} diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..48eb1d6 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,321 @@ +use regex::Regex; +use std::collections::HashMap; +use thiserror::Error; +use anyhow::Result; + +#[derive(Debug, Error, Clone)] +pub enum ValidationError { + #[error("Missing required variable: {0}")] + MissingVariable(String), + #[error("Invalid format for {0}: {1}")] + InvalidFormat(String, String), + #[error("Empty value for required variable: {0}")] + EmptyValue(String), + #[error("Unknown validation type: {0}")] + UnknownType(String), +} + +#[derive(Debug, Clone)] +pub struct ValidationRule { + pub key_pattern: String, + pub r#type: ValidationType, + pub required: bool, + pub description: String, +} + +#[derive(Debug, Clone)] +pub enum ValidationType { + Url, + Email, + Uuid, + ApiKey, + Boolean, + Integer, + DatabaseUrl, + Jwt, + AwsKey, + GitHubToken, + SlackWebhook, + Custom(Regex), +} + +impl ValidationType { + pub fn validate(&self, value: &str) -> bool { + match self { + ValidationType::Url => validate_url(value), + ValidationType::Email => validate_email(value), + ValidationType::Uuid => validate_uuid(value), + ValidationType::ApiKey => validate_api_key(value), + ValidationType::Boolean => validate_boolean(value), + ValidationType::Integer => validate_integer(value), + ValidationType::DatabaseUrl => validate_database_url(value), + ValidationType::Jwt => validate_jwt(value), + ValidationType::AwsKey => validate_aws_key(value), + ValidationType::GitHubToken => validate_github_token(value), + ValidationType::SlackWebhook => validate_slack_webhook(value), + ValidationType::Custom(re) => re.is_match(value), + } + } + + pub fn from_string(s: &str) -> Option { + match s.to_lowercase().as_str() { + "url" => Some(ValidationType::Url), + "email" => Some(ValidationType::Email), + "uuid" => Some(ValidationType::Uuid), + "apikey" | "api_key" => Some(ValidationType::ApiKey), + "boolean" | "bool" => Some(ValidationType::Boolean), + "integer" | "int" => Some(ValidationType::Integer), + "database" | "database_url" => Some(ValidationType::DatabaseUrl), + "jwt" => Some(ValidationType::Jwt), + "aws" | "aws_key" => Some(ValidationType::AwsKey), + "github" | "github_token" => Some(ValidationType::GitHubToken), + "slack" | "slack_webhook" => Some(ValidationType::SlackWebhook), + _ => None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ValidationResult { + pub passed: Vec, + pub failed: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +pub struct ValidationFailure { + pub key: String, + pub error: ValidationError, + pub value: Option, +} + +pub fn validate_url(value: &str) -> bool { + let url_pattern = Regex::new(r#"^(https?)://[^\s/$.?#].[^\s]*$"#).unwrap(); + url_pattern.is_match(value.trim()) +} + +pub fn validate_email(value: &str) -> bool { + let email_pattern = Regex::new(r#"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"#).unwrap(); + email_pattern.is_match(value.trim()) +} + +pub fn validate_uuid(value: &str) -> bool { + let uuid_pattern = Regex::new(r#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#).unwrap(); + uuid_pattern.is_match(value.trim()) +} + +pub fn validate_api_key(value: &str) -> bool { + if value.len() < 16 { + return false; + } + let api_key_patterns = [ + Regex::new(r#"^sk-[a-zA-Z0-9]{20,}$"#).unwrap(), + Regex::new(r#"^pk_[a-zA-Z0-9]{20,}$"#).unwrap(), + Regex::new(r#"^[A-Za-z0-9-_]{32,}$"#).unwrap(), + ]; + api_key_patterns.iter().any(|p| p.is_match(value)) +} + +pub fn validate_boolean(value: &str) -> bool { + let v = value.to_lowercase(); + matches!(v.as_str(), "true" | "false" | "1" | "0" | "yes" | "no") +} + +pub fn validate_integer(value: &str) -> bool { + value.trim().parse::().is_ok() +} + +pub fn validate_database_url(value: &str) -> bool { + let db_patterns = [ + Regex::new(r#"^postgres://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+/[^\s]+$"#).unwrap(), + Regex::new(r#"^postgresql://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+/[^\s]+$"#).unwrap(), + Regex::new(r#"^mysql://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+/[^\s]+$"#).unwrap(), + Regex::new(r#"^mongodb(\+srv)?://[^\s@]+:[^\s@]+@[^\s@]+/[^\s]+$"#).unwrap(), + Regex::new(r#"^redis://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+$"#).unwrap(), + Regex::new(r#"^sqlite:///.*\.db$"#).unwrap(), + ]; + db_patterns.iter().any(|p| p.is_match(value.trim())) +} + +pub fn validate_jwt(value: &str) -> bool { + let jwt_pattern = Regex::new(r#"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$"#).unwrap(); + jwt_pattern.is_match(value.trim()) +} + +pub fn validate_aws_key(value: &str) -> bool { + let aws_patterns = [ + Regex::new(r#"^AKIA[0-9A-Z]{16}$"#).unwrap(), + Regex::new(r#"^aws_access_key_id\s*=\s*[A-Z0-9]{20}$"#).unwrap(), + ]; + aws_patterns.iter().any(|p| p.is_match(value)) +} + +pub fn validate_github_token(value: &str) -> bool { + let github_patterns = [ + Regex::new(r#"^gh[pousr]_[A-Za-z0-9_]{36,}$"#).unwrap(), + Regex::new(r#"^github_pat_[A-Za-z0-9_]{22,}$"#).unwrap(), + ]; + github_patterns.iter().any(|p| p.is_match(value.trim())) +} + +pub fn validate_slack_webhook(value: &str) -> bool { + let slack_pattern = Regex::new(r#"^https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+$"#).unwrap(); + slack_pattern.is_match(value.trim()) +} + +pub fn infer_type(key: &str, _value: &str) -> Option { + let key_lower = key.to_lowercase(); + + if key_lower.contains("url") || key_lower.contains("uri") { + if key_lower.contains("database") || key_lower.contains("db_") { + return Some(ValidationType::DatabaseUrl); + } + return Some(ValidationType::Url); + } + + if key_lower.contains("email") || key_lower.ends_with("_mail") { + return Some(ValidationType::Email); + } + + if key_lower.contains("uuid") || key_lower == "id" { + return Some(ValidationType::Uuid); + } + + if key_lower.contains("secret") || key_lower.contains("key") || key_lower.contains("token") { + if key_lower.contains("aws") { + return Some(ValidationType::AwsKey); + } + if key_lower.contains("github") { + return Some(ValidationType::GitHubToken); + } + if key_lower.contains("jwt") || key_lower.contains("bearer") { + return Some(ValidationType::Jwt); + } + return Some(ValidationType::ApiKey); + } + + if key_lower.contains("boolean") || key_lower.contains("enabled") || key_lower.contains("active") { + return Some(ValidationType::Boolean); + } + + if key_lower.contains("port") || key_lower.contains("timeout") || key_lower.contains("retry") { + return Some(ValidationType::Integer); + } + + if key_lower.contains("slack") || key_lower.contains("webhook") { + return Some(ValidationType::SlackWebhook); + } + + None +} + +pub fn validate_value(key: &str, value: &str, expected_type: Option<&ValidationType>) -> Result<(), ValidationError> { + if value.trim().is_empty() { + return Err(ValidationError::EmptyValue(key.to_string())); + } + + if let Some(validator) = expected_type { + if !validator.validate(value) { + return Err(ValidationError::InvalidFormat( + key.to_string(), + format!("{:?}", validator), + )); + } + } + + Ok(()) +} + +pub struct Validator { + pub rules: Vec, +} + +impl Validator { + pub fn new() -> Self { + Self { rules: Vec::new() } + } + + pub fn with_builtin_rules() -> Self { + let mut rules = Vec::new(); + + rules.push(ValidationRule { + key_pattern: ".*_URL".to_string(), + r#type: ValidationType::Url, + required: false, + description: "URL format validation".to_string(), + }); + + rules.push(ValidationRule { + key_pattern: ".*_EMAIL".to_string(), + r#type: ValidationType::Email, + required: false, + description: "Email format validation".to_string(), + }); + + rules.push(ValidationRule { + key_pattern: "DATABASE_URL".to_string(), + r#type: ValidationType::DatabaseUrl, + required: true, + description: "Database connection URL".to_string(), + }); + + rules.push(ValidationRule { + key_pattern: ".*_API_KEY".to_string(), + r#type: ValidationType::ApiKey, + required: false, + description: "API key format".to_string(), + }); + + rules.push(ValidationRule { + key_pattern: "AWS_ACCESS_KEY.*".to_string(), + r#type: ValidationType::AwsKey, + required: false, + description: "AWS access key format".to_string(), + }); + + Self { rules } + } + + pub fn validate(&self, key: &str, value: &str, required: bool) -> Result<(), ValidationError> { + if value.trim().is_empty() { + if required { + return Err(ValidationError::MissingVariable(key.to_string())); + } + return Ok(()); + } + + for rule in &self.rules { + if regex::Regex::new(&rule.key_pattern) + .unwrap() + .is_match(key) + { + if !rule.r#type.validate(value) { + return Err(ValidationError::InvalidFormat( + key.to_string(), + rule.description.clone(), + )); + } + } + } + + Ok(()) + } + + pub fn validate_all(&self, variables: &HashMap) -> ValidationResult { + let mut result = ValidationResult::default(); + + for (key, value) in variables { + if let Err(e) = self.validate(key, value, true) { + result.failed.push(ValidationFailure { + key: key.clone(), + error: e, + value: Some(value.clone()), + }); + } else { + result.passed.push(key.clone()); + } + } + + result + } +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..a3926d3 --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,66 @@ +#[cfg(test)] +mod cli_tests { + use assert_cmd::Command; + use std::fs; + + #[test] + fn test_cli_help() { + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("--help").assert().success(); + } + + #[test] + fn test_cli_version() { + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("--version").assert().success(); + } + + #[test] + fn test_cli_validate_missing_file() { + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("validate").arg("--path").arg("nonexistent.env").assert().failure(); + } + + #[test] + fn test_cli_generate_missing_file() { + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("generate").arg("--path").arg("nonexistent.env").assert().failure(); + } + + #[test] + fn test_cli_check_missing_file() { + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("check").arg("--path").arg("nonexistent.env").assert().failure(); + } + + #[test] + fn test_cli_validate_command() { + let test_dir = "test_validate_dir"; + fs::create_dir_all(test_dir).unwrap(); + fs::write(format!("{}/.env", test_dir), "DATABASE_URL=postgres://localhost/db\nSECRET_KEY=secret").unwrap(); + + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("validate").arg("--path").arg(format!("{}/.env", test_dir)).assert().success(); + + fs::remove_dir_all(test_dir).ok(); + } + + #[test] + fn test_cli_generate_command() { + let test_dir = "test_generate_dir"; + fs::create_dir_all(test_dir).unwrap(); + fs::write(format!("{}/.env", test_dir), "DATABASE_URL=postgres://localhost/db\nSECRET_KEY=secret").unwrap(); + + let mut cmd = Command::cargo_bin("env-guard").unwrap(); + cmd.arg("generate") + .arg("--path") + .arg(format!("{}/.env", test_dir)) + .arg("--output") + .arg(format!("{}/.env.example", test_dir)) + .assert().success(); + + assert!(fs::read_to_string(format!("{}/.env.example", test_dir)).unwrap().contains("DATABASE_URL")); + + fs::remove_dir_all(test_dir).ok(); + } +} diff --git a/tests/env_parser_test.rs b/tests/env_parser_test.rs new file mode 100644 index 0000000..4247be3 --- /dev/null +++ b/tests/env_parser_test.rs @@ -0,0 +1,96 @@ +#[cfg(test)] +mod env_parser_tests { + use env_guard::env_parser::{EnvFile, EnvEntry, parse_dotenv, extract_key_value}; + + #[test] + fn test_parse_simple_env() { + let content = r#" +DATABASE_URL=postgresql://user:pass@localhost:5432/db +SECRET_KEY=mysecret123 +DEBUG=true +"#; + let env_file = EnvFile::parse(content).unwrap(); + assert_eq!(env_file.len(), 3); + assert!(env_file.entries.contains_key("DATABASE_URL")); + assert!(env_file.entries.contains_key("SECRET_KEY")); + assert!(env_file.entries.contains_key("DEBUG")); + } + + #[test] + fn test_parse_quoted_values() { + let content = r#" +API_KEY="my-secret-key-with-spaces" +DATABASE_URL='postgresql://user:pass@localhost:5432/db' +"#; + let env_file = EnvFile::parse(content).unwrap(); + assert_eq!(env_file.len(), 2); + + let api_key = env_file.entries.get("API_KEY").unwrap(); + assert_eq!(api_key.value, "\"my-secret-key-with-spaces\""); + assert!(api_key.is_quoted); + } + + #[test] + fn test_parse_comments() { + let content = r#" +# This is a comment +DATABASE_URL=postgresql://localhost:5432/db +# Another comment +SECRET_KEY=secret +"#; + let env_file = EnvFile::parse(content).unwrap(); + assert_eq!(env_file.len(), 2); + assert_eq!(env_file.comments.len(), 2); + } + + #[test] + fn test_parse_empty_lines() { + let content = r#" +DATABASE_URL=postgres://localhost/db + + +SECRET_KEY=secret + + +DEBUG=true +"#; + let env_file = EnvFile::parse(content).unwrap(); + assert_eq!(env_file.len(), 3); + } + + #[test] + fn test_extract_key_value() { + let line = "DATABASE_URL=postgresql://localhost:5432/db"; + let (key, value) = extract_key_value(line).unwrap(); + assert_eq!(key, "DATABASE_URL"); + assert_eq!(value, "postgresql://localhost:5432/db"); + } + + #[test] + fn test_unquoted_value() { + let entry = EnvEntry::new("TEST".to_string(), "\"quoted value\"".to_string(), 1); + assert_eq!(entry.unquoted_value(), "quoted value"); + + let unquoted = EnvEntry::new("TEST2".to_string(), "plain value".to_string(), 2); + assert_eq!(unquoted.unquoted_value(), "plain value"); + } + + #[test] + fn test_special_characters_in_value() { + let content = r#"SPECIAL=value_with_underscores-and-hyphens"#; + let env_file = EnvFile::parse(content).unwrap(); + assert!(env_file.entries.contains_key("SPECIAL")); + } + + #[test] + fn test_numeric_values() { + let content = r#" +PORT=3000 +TIMEOUT=60 +RATIO=3.14 +"#; + let env_file = EnvFile::parse(content).unwrap(); + assert_eq!(env_file.len(), 3); + assert_eq!(env_file.entries.get("PORT").unwrap().value, "3000"); + } +} diff --git a/tests/secrets_test.rs b/tests/secrets_test.rs new file mode 100644 index 0000000..b4f9bcc --- /dev/null +++ b/tests/secrets_test.rs @@ -0,0 +1,112 @@ +#[cfg(test)] +mod secrets_tests { + use env_guard::secrets::{ + scan_file, redact_secret, format_secret_match, + get_builtin_patterns, SecretSeverity + }; + use std::fs; + + #[test] + fn test_redact_secret_short() { + assert_eq!(redact_secret("abc"), "***"); + } + + #[test] + fn test_redact_secret_long() { + let result = redact_secret("my-secret-api-key-12345"); + assert!(result.starts_with("my-s")); + assert!(result.contains('*')); + assert!(result.len() < 30); + } + + #[test] + fn test_redact_secret_exact_8_chars() { + let result = redact_secret("12345678"); + assert_eq!(result, "********"); + } + + #[test] + fn test_get_builtin_patterns() { + let patterns = get_builtin_patterns(); + assert!(!patterns.is_empty()); + + let has_aws = patterns.iter().any(|p| p.name.contains("AWS")); + let has_github = patterns.iter().any(|p| p.name.contains("GitHub")); + let has_jwt = patterns.iter().any(|p| p.name.contains("JWT")); + + assert!(has_aws); + assert!(has_github); + assert!(has_jwt); + } + + #[test] + fn test_scan_file_with_secrets() { + let content = r#" +const apiKey = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; +const password = "super_secret_password"; +const awsKey = "AKIAIOSFODNN7EXAMPLE"; +"#; + let test_file = "test_secrets_temp.txt"; + fs::write(test_file, content).unwrap(); + + let matches = scan_file(test_file, false).unwrap(); + + assert!(!matches.is_empty()); + let has_api_key = matches.iter().any(|m| m.secret_type.contains("API") || m.secret_type.contains("OpenAI")); + assert!(has_api_key); + + fs::remove_file(test_file).ok(); + } + + #[test] + fn test_scan_file_without_secrets() { + let content = r#" +const apiUrl = "https://api.example.com"; +const port = 3000; +const debug = true; +"#; + let test_file = "test_no_secrets_temp.txt"; + fs::write(test_file, content).unwrap(); + + let matches = scan_file(test_file, false).unwrap(); + assert!(matches.is_empty()); + + fs::remove_file(test_file).ok(); + } + + #[test] + fn test_secret_severity_levels() { + assert_eq!(SecretSeverity::Critical.as_str(), "CRITICAL"); + assert_eq!(SecretSeverity::High.as_str(), "HIGH"); + assert_eq!(SecretSeverity::Medium.as_str(), "MEDIUM"); + assert_eq!(SecretSeverity::Low.as_str(), "LOW"); + } + + #[test] + fn test_github_token_pattern() { + let content = r#"const token = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";"#; + let test_file = "test_github_temp.txt"; + fs::write(test_file, content).unwrap(); + + let matches = scan_file(test_file, false).unwrap(); + let has_github = matches.iter().any(|m| m.secret_type.contains("GitHub")); + + assert!(has_github); + + fs::remove_file(test_file).ok(); + } + + #[test] + fn test_jwt_pattern() { + let content = r#"const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";"#; + let test_file = "test_jwt_temp.txt"; + fs::write(test_file, content).unwrap(); + + let matches = scan_file(test_file, false).unwrap(); + let has_jwt = matches.iter().any(|m| m.secret_type.contains("JWT")); + + assert!(has_jwt); + + fs::remove_file(test_file).ok(); + } +} diff --git a/tests/validation_test.rs b/tests/validation_test.rs new file mode 100644 index 0000000..c261636 --- /dev/null +++ b/tests/validation_test.rs @@ -0,0 +1,139 @@ +#[cfg(test)] +mod validation_tests { + use env_guard::validation::{ + validate_url, validate_email, validate_uuid, validate_api_key, + validate_boolean, validate_integer, validate_database_url, validate_jwt, + validate_aws_key, validate_github_token, validate_slack_webhook, + validate_value, ValidationError, ValidationType, Validator + }; + + #[test] + fn test_valid_urls() { + assert!(validate_url("https://example.com")); + assert!(validate_url("http://localhost:3000")); + assert!(validate_url("https://api.example.com/v1/users")); + } + + #[test] + fn test_invalid_urls() { + assert!(!validate_url("not-a-url")); + assert!(!validate_url("ftp://invalid.com")); + assert!(!validate_url("")); + } + + #[test] + fn test_valid_emails() { + assert!(validate_email("user@example.com")); + assert!(validate_email("test.user+tag@domain.co.uk")); + assert!(validate_email("admin@sub.domain.com")); + } + + #[test] + fn test_invalid_emails() { + assert!(!validate_email("not-an-email")); + assert!(!validate_email("@nodomain.com")); + assert!(!validate_email("")); + } + + #[test] + fn test_valid_uuids() { + assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000")); + assert!(validate_uuid("f47ac10b-58cc-4372-a567-0e02b2c3d479")); + } + + #[test] + fn test_invalid_uuids() { + assert!(!validate_uuid("not-a-uuid")); + assert!(!validate_uuid("")); + } + + #[test] + fn test_valid_api_keys() { + assert!(validate_api_key("sk-test123456789012345678901234")); + assert!(validate_api_key("pk_live_abcdefghijklmnopqrstuvwx")); + } + + #[test] + fn test_invalid_api_keys() { + assert!(!validate_api_key("too-short")); + assert!(!validate_api_key("")); + } + + #[test] + fn test_valid_booleans() { + assert!(validate_boolean("true")); + assert!(validate_boolean("false")); + assert!(validate_boolean("1")); + assert!(validate_boolean("0")); + } + + #[test] + fn test_invalid_booleans() { + assert!(!validate_boolean("maybe")); + assert!(!validate_boolean("2")); + assert!(!validate_boolean("")); + } + + #[test] + fn test_valid_integers() { + assert!(validate_integer("123")); + assert!(validate_integer("-456")); + assert!(validate_integer("0")); + } + + #[test] + fn test_invalid_integers() { + assert!(!validate_integer("12.34")); + assert!(!validate_integer("abc")); + assert!(!validate_integer("")); + } + + #[test] + fn test_valid_database_urls() { + assert!(validate_database_url("postgresql://user:pass@localhost:5432/db")); + assert!(validate_database_url("mysql://user:pass@localhost:3306/db")); + assert!(validate_database_url("mongodb://localhost:27017/db")); + assert!(validate_database_url("redis://localhost:6379")); + } + + #[test] + fn test_invalid_database_urls() { + assert!(!validate_database_url("not-a-db-url")); + assert!(!validate_database_url("")); + } + + #[test] + fn test_valid_jwts() { + assert!(validate_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")); + } + + #[test] + fn test_invalid_jwts() { + assert!(!validate_jwt("not-a-jwt")); + assert!(!validate_jwt("")); + } + + #[test] + fn test_valid_github_tokens() { + assert!(validate_github_token("ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); + } + + #[test] + fn test_valid_slack_webhooks() { + assert!(validate_slack_webhook("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX")); + } + + #[test] + fn test_validate_value() { + assert!(validate_value("TEST", "value", None).is_ok()); + assert!(validate_value("TEST", "", None).is_err()); + } + + #[test] + fn test_validator_with_builtin_rules() { + let validator = Validator::with_builtin_rules(); + let result = validator.validate_all(&std::collections::HashMap::new()); + assert!(result.failed.is_empty()); + assert!(result.passed.is_empty()); + } +}