Initial commit: env-guard CLI tool with CI/CD
Some checks failed
CI / test (push) Failing after 9s
CI / binary (push) Has been skipped
CI / release (push) Has been skipped

This commit is contained in:
CI Bot
2026-02-06 10:01:25 +00:00
commit fc90e05ebb
18 changed files with 2670 additions and 0 deletions

23
.env.example Normal file
View File

@@ -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=<your-database-url>
# Security
SECRET_KEY=<your-secret-key>
# API Keys
API_KEY=<your-api-key>
# URLs
API_URL=https://api.example.com
# Server
PORT=3000
HOST=localhost

66
.gitea/workflows/ci.yml Normal file
View File

@@ -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"

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
target/
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
.env
.env.local
.env.*.local
*.pem
test_*.txt
test_*.env
*.log

38
Cargo.toml Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

296
README.md Normal file
View File

@@ -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.

367
src/commands.rs Normal file
View File

@@ -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<String> = Vec::new();
let warnings: Vec<String> = 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 "<your-secret-key>".to_string();
}
if key_lower.contains("password") || key_lower.contains("pwd") {
return "<your-password>".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 "<your-aws-access-key-id>".to_string();
}
if key_lower.contains("aws") && key_lower.contains("secret") {
return "<your-aws-secret-access-key>".to_string();
}
"<your-value>".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<SecretSeverity, Vec<SecretMatch>> = 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(())
}

102
src/config.rs Normal file
View File

@@ -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<String>,
pub description: Option<String>,
pub pattern: Option<String>,
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
pub name: Option<String>,
pub framework: Option<String>,
pub variables: Vec<EnvVarSchema>,
pub ignore_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvGuardConfig {
pub projects: HashMap<String, ProjectConfig>,
pub global_ignore: Vec<String>,
}
impl EnvGuardConfig {
pub fn new() -> Result<Self> {
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<String>,
pub framework: Option<String>,
pub variables: Vec<EnvVarSchema>,
}
impl SchemaFile {
pub fn load(path: &str) -> Result<Self> {
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(())
}
}

193
src/env_parser.rs Normal file
View File

@@ -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<String>,
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<String, EnvEntry>,
pub raw_lines: Vec<String>,
pub comments: Vec<CommentLine>,
}
#[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<Self> {
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<Self> {
let mut env_file = Self::new();
let mut current_key: Option<String> = 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<HashMap<String, String>> {
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))
}

383
src/framework.rs Normal file
View File

@@ -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<EnvVarSchema> {
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<EnvVarSchema>,
}
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,
}
}

6
src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod config;
pub mod env_parser;
pub mod validation;
pub mod secrets;
pub mod framework;
pub mod commands;

165
src/main.rs Normal file
View File

@@ -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::<String>("path").map(|s| s.as_str()).unwrap_or(".");
let schema = sub_matches.get_one::<String>("schema").map(|s| s.as_str());
scan(path, schema)?;
}
Some(("validate", sub_matches)) => {
let path = sub_matches.get_one::<String>("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::<String>("path").map(|s| s.as_str()).unwrap_or(".env");
let output = sub_matches.get_one::<String>("output").map(|s| s.as_str());
generate(path, output)?;
}
Some(("secrets", sub_matches)) => {
let path = sub_matches.get_one::<String>("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::<String>("framework").map(|s| s.as_str());
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str());
init(framework, path)?;
}
Some(("check", sub_matches)) => {
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str()).unwrap_or(".env");
check(path)?;
}
_ => {
let _ = Command::new("env-guard").print_help();
}
}
Ok(())
}

257
src/secrets.rs Normal file
View File

@@ -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<SecretPattern> {
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<Vec<SecretMatch>> {
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<Vec<SecretMatch>> {
let mut all_matches = Vec::new();
let patterns = get_builtin_patterns();
let ignore_set: Vec<String> = 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<SecretMatch>,
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
)
}

321
src/validation.rs Normal file
View File

@@ -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<Self> {
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<String>,
pub failed: Vec<ValidationFailure>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ValidationFailure {
pub key: String,
pub error: ValidationError,
pub value: Option<String>,
}
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::<i64>().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<ValidationType> {
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<ValidationRule>,
}
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<String, String>) -> 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
}
}

66
tests/cli_test.rs Normal file
View File

@@ -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();
}
}

96
tests/env_parser_test.rs Normal file
View File

@@ -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");
}
}

112
tests/secrets_test.rs Normal file
View File

@@ -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();
}
}

139
tests/validation_test.rs Normal file
View File

@@ -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());
}
}