Initial commit: env-guard CLI tool with CI/CD
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal 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
66
.gitea/workflows/ci.yml
Normal 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
19
.gitignore
vendored
Normal 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
38
Cargo.toml
Normal 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
21
LICENSE
Normal 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
296
README.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# Env Guard
|
||||||
|
|
||||||
|
A powerful Rust CLI tool that automatically detects, validates, and secures environment variables across different environments.
|
||||||
|
|
||||||
|
[](https://7000pct.gitea.bloupla.net/7000pctAUTO/env-guard/actions)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](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
367
src/commands.rs
Normal 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
102
src/config.rs
Normal 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
193
src/env_parser.rs
Normal 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
383
src/framework.rs
Normal 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
6
src/lib.rs
Normal 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
165
src/main.rs
Normal 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
257
src/secrets.rs
Normal 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
321
src/validation.rs
Normal 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
66
tests/cli_test.rs
Normal 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
96
tests/env_parser_test.rs
Normal 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
112
tests/secrets_test.rs
Normal 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
139
tests/validation_test.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user