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

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

367
src/commands.rs Normal file
View File

@@ -0,0 +1,367 @@
use std::collections::HashMap;
use crate::config::{EnvGuardConfig, SchemaFile};
use crate::env_parser::EnvFile;
use crate::framework::Framework;
use crate::secrets::{scan_directory, format_secret_match, SecretMatch, SecretSeverity};
use crate::validation::{Validator, validate_value};
use std::fs;
use std::path::Path;
pub fn scan(path: &str, schema_path: Option<&str>) -> Result<(), anyhow::Error> {
println!("Scanning .env files in: {}", path);
let env_file = EnvFile::from_path(path)?;
println!("Found {} variables in .env file", env_file.len());
if let Some(schema) = schema_path {
if Path::new(schema).exists() {
let schema = SchemaFile::load(schema)?;
let mut missing = Vec::new();
let mut extra = Vec::new();
for schema_var in &schema.variables {
if !env_file.entries.contains_key(&schema_var.key) {
if schema_var.required {
missing.push(&schema_var.key);
}
}
}
for (key, _) in &env_file.entries {
let found = schema.variables.iter().any(|v| v.key == *key);
if !found {
extra.push(key);
}
}
if !missing.is_empty() {
println!("\nMissing required variables:");
for key in &missing {
println!(" - {}", key);
}
}
if !extra.is_empty() {
println!("\nExtra variables not in schema:");
for key in &extra {
println!(" - {}", key);
}
}
if missing.is_empty() && extra.is_empty() {
println!("\nAll schema variables are present and accounted for!");
}
}
} else {
let framework = Framework::detect(path);
if framework != Framework::Unknown {
println!("\nDetected framework: {}", framework.name());
let default_schema = framework.get_default_schema();
let mut missing = Vec::new();
for schema_var in &default_schema {
if !env_file.entries.contains_key(&schema_var.key) {
if schema_var.required {
missing.push(&schema_var.key);
}
}
}
if !missing.is_empty() {
println!("\nMissing required variables for {}:", framework.name());
for key in &missing {
println!(" - {}", key);
}
}
}
}
Ok(())
}
pub fn validate(path: &str, strict: bool) -> Result<(), anyhow::Error> {
println!("Validating .env file: {}", path);
if !Path::new(path).exists() {
return Err(anyhow::anyhow!("File not found: {}", path));
}
let content = fs::read_to_string(path)?;
let env_file = EnvFile::parse(&content)?;
let _validator = Validator::with_builtin_rules();
let mut errors: Vec<String> = Vec::new();
let warnings: Vec<String> = Vec::new();
for (key, entry) in &env_file.entries {
let value = entry.unquoted_value();
if value.trim().is_empty() {
if strict {
errors.push(format!("Empty value for required variable: {}", key));
}
continue;
}
if let Err(e) = validate_value(key, &value, None) {
errors.push(format!("Validation error for {}: {}", key, e));
}
}
if !errors.is_empty() {
println!("\nValidation Errors:");
for error in &errors {
println!(" - {}", error);
}
} else {
println!("\nValidation passed!");
}
if !warnings.is_empty() {
println!("\nWarnings:");
for warning in &warnings {
println!(" ! {}", warning);
}
}
if !errors.is_empty() && strict {
return Err(anyhow::anyhow!("Validation failed with {} errors", errors.len()));
}
Ok(())
}
pub fn generate(path: &str, output: Option<&str>) -> Result<(), anyhow::Error> {
println!("Generating .env.example from: {}", path);
if !Path::new(path).exists() {
return Err(anyhow::anyhow!("File not found: {}", path));
}
let content = fs::read_to_string(path)?;
let env_file = EnvFile::parse(&content)?;
let output_path = output.unwrap_or(".env.example");
let mut output_content = String::new();
output_content.push_str("# This is an auto-generated .env.example file\n");
output_content.push_str("# Copy this to .env.example and fill in your values\n\n");
let framework = Framework::detect(path);
let schema = if framework != Framework::Unknown {
framework.get_default_schema()
} else {
Vec::new()
};
for key in env_file.entries.keys() {
let placeholder = get_placeholder_for_key(key, &schema);
output_content.push_str(&format!("# {}={}\n", key, placeholder));
}
fs::write(output_path, output_content)?;
println!("Generated .env.example at: {}", output_path);
Ok(())
}
fn get_placeholder_for_key(key: &str, schema: &[crate::config::EnvVarSchema]) -> String {
let key_lower = key.to_lowercase();
if let Some(schema_var) = schema.iter().find(|v| v.key == key) {
if let Some(default) = &schema_var.default {
return default.clone();
}
if let Some(desc) = &schema_var.description {
return format!("<{}>", desc.to_lowercase().replace(' ', "_"));
}
}
if key_lower.contains("url") || key_lower.contains("uri") {
return "https://example.com/api".to_string();
}
if key_lower.contains("email") {
return "user@example.com".to_string();
}
if key_lower.contains("secret") || key_lower.contains("key") || key_lower.contains("token") {
return "<your-secret-key>".to_string();
}
if key_lower.contains("password") || key_lower.contains("pwd") {
return "<your-password>".to_string();
}
if key_lower.contains("database") || key_lower.contains("db_") {
return "postgresql://user:password@localhost:5432/dbname".to_string();
}
if key_lower.contains("redis") {
return "redis://localhost:6379".to_string();
}
if key_lower.contains("port") {
return "3000".to_string();
}
if key_lower.contains("host") {
return "localhost".to_string();
}
if key_lower.contains("debug") || key_lower.contains("enabled") {
return "true".to_string();
}
if key_lower.contains("aws") && key_lower.contains("key") {
return "<your-aws-access-key-id>".to_string();
}
if key_lower.contains("aws") && key_lower.contains("secret") {
return "<your-aws-secret-access-key>".to_string();
}
"<your-value>".to_string()
}
pub fn secrets_cmd(path: &str, strict: bool) -> Result<(), anyhow::Error> {
println!("Scanning for secrets in: {}", path);
let matches = scan_directory(path, strict, None)?;
if matches.is_empty() {
println!("\nNo secrets found!");
return Ok(());
}
let mut by_severity: HashMap<SecretSeverity, Vec<SecretMatch>> = HashMap::new();
for m in &matches {
by_severity.entry(m.severity.clone()).or_default().push(m.clone());
}
let order = [SecretSeverity::Critical, SecretSeverity::High, SecretSeverity::Medium, SecretSeverity::Low];
let mut total_found = 0;
for severity in order {
if let Some(matches) = by_severity.get(&severity) {
let count = matches.len();
println!("\n{} - {} found:", severity.as_str(), count);
for m in matches {
println!(" {}", format_secret_match(m));
total_found += 1;
}
}
}
println!("\nTotal secrets found: {}", total_found);
if strict {
return Err(anyhow::anyhow!("Secrets found in code!"));
}
Ok(())
}
pub fn init(framework: Option<&str>, path: Option<&str>) -> Result<(), anyhow::Error> {
let scan_path = path.unwrap_or(".");
let detected = Framework::detect(scan_path);
let framework_name = framework.unwrap_or_else(|| {
if detected != Framework::Unknown {
detected.name()
} else {
"custom"
}
});
println!("Initializing env-guard configuration...");
println!("Framework: {}", framework_name);
let _config = EnvGuardConfig::new()?;
let mut schema = SchemaFile::default();
if framework_name != "custom" {
let detected_framework = Framework::detect(scan_path);
if detected_framework != Framework::Unknown {
schema.variables = detected_framework.get_default_schema();
println!("Using default schema for {}", detected_framework.name());
} else {
schema.variables = Vec::new();
}
} else {
schema.variables = Vec::new();
}
let schema_path = ".env.schema.json";
schema.save(schema_path)?;
println!("Created schema file at: {}", schema_path);
println!("Variables defined: {}", schema.variables.len());
Ok(())
}
pub fn check(path: &str) -> Result<(), anyhow::Error> {
println!("Checking .env file: {}", path);
if !Path::new(path).exists() {
return Err(anyhow::anyhow!("File not found: {}", path));
}
let content = fs::read_to_string(path)?;
let env_file = EnvFile::parse(&content)?;
println!("\nSummary:");
println!(" Total variables: {}", env_file.len());
let mut has_secrets = Vec::new();
let mut has_urls = Vec::new();
let mut has_databases = Vec::new();
for (key, entry) in &env_file.entries {
let value = entry.unquoted_value();
if key.to_lowercase().contains("secret")
|| key.to_lowercase().contains("password")
|| key.to_lowercase().contains("key")
{
has_secrets.push(key.clone());
}
if value.contains("http://") || value.contains("https://") {
has_urls.push(key.clone());
}
if value.contains("postgresql://")
|| value.contains("mysql://")
|| value.contains("mongodb://")
{
has_databases.push(key.clone());
}
}
println!(" Secrets/API keys: {}", has_secrets.len());
println!(" URLs: {}", has_urls.len());
println!(" Database connections: {}", has_databases.len());
if !has_secrets.is_empty() {
println!("\nVariables containing secrets:");
for key in &has_secrets {
println!(" - {}", key);
}
}
if !has_databases.is_empty() {
println!("\nDatabase connection variables:");
for key in &has_databases {
println!(" - {}", key);
}
}
let framework = Framework::detect(".");
if framework != Framework::Unknown {
println!("\nDetected framework: {}", framework.name());
}
Ok(())
}

102
src/config.rs Normal file
View File

@@ -0,0 +1,102 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use thiserror::Error;
use anyhow::Result;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Configuration file error: {0}")]
FileError(String),
#[error("Parse error: {0}")]
ParseError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvVarSchema {
pub key: String,
pub required: bool,
pub r#type: Option<String>,
pub description: Option<String>,
pub pattern: Option<String>,
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
pub name: Option<String>,
pub framework: Option<String>,
pub variables: Vec<EnvVarSchema>,
pub ignore_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvGuardConfig {
pub projects: HashMap<String, ProjectConfig>,
pub global_ignore: Vec<String>,
}
impl EnvGuardConfig {
pub fn new() -> Result<Self> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
let config_path = config_dir.join("env-guard").join("config.toml");
if config_path.exists() {
let content = fs::read_to_string(&config_path)?;
let config: EnvGuardConfig = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
Ok(config)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
let config_path = config_dir.join("env-guard");
if !config_path.exists() {
fs::create_dir_all(&config_path)?;
}
let config_file = config_path.join("config.toml");
let content = toml::to_string(self)
.map_err(|e| anyhow::anyhow!("Failed to serialize config: {}", e))?;
fs::write(&config_file, content)?;
Ok(())
}
pub fn get_project_config(&self, project: &str) -> Option<&ProjectConfig> {
self.projects.get(project)
}
pub fn add_project(&mut self, name: String, config: ProjectConfig) {
self.projects.insert(name, config);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SchemaFile {
#[serde(rename = "$schema")]
pub schema_version: Option<String>,
pub framework: Option<String>,
pub variables: Vec<EnvVarSchema>,
}
impl SchemaFile {
pub fn load(path: &str) -> Result<Self> {
let content = fs::read_to_string(path)?;
let schema: SchemaFile = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse schema file: {}", e))?;
Ok(schema)
}
pub fn save(&self, path: &str) -> Result<()> {
let content = serde_json::to_string_pretty(self)
.map_err(|e| anyhow::anyhow!("Failed to serialize schema: {}", e))?;
fs::write(path, content)?;
Ok(())
}
}

193
src/env_parser.rs Normal file
View File

@@ -0,0 +1,193 @@
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use thiserror::Error;
use anyhow::Result;
#[derive(Debug, Error)]
pub enum EnvParseError {
#[error("File not found: {0}")]
FileNotFound(String),
#[error("Invalid line format: {0}")]
InvalidLine(String),
#[error("Parse error: {0}")]
ParseError(String),
}
#[derive(Debug, Clone, Default)]
pub struct EnvEntry {
pub key: String,
pub value: String,
pub comment: Option<String>,
pub line_number: usize,
pub is_quoted: bool,
pub is_multiline: bool,
}
impl EnvEntry {
pub fn new(key: String, value: String, line_number: usize) -> Self {
let is_quoted = (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''));
Self {
key,
value,
comment: None,
line_number,
is_quoted,
is_multiline: false,
}
}
pub fn unquoted_value(&self) -> String {
let val = self.value.trim();
if self.is_quoted {
val[1..val.len()-1].to_string()
} else {
val.to_string()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EnvFile {
pub entries: HashMap<String, EnvEntry>,
pub raw_lines: Vec<String>,
pub comments: Vec<CommentLine>,
}
#[derive(Debug, Clone)]
pub struct CommentLine {
pub text: String,
pub line_number: usize,
}
impl EnvFile {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
raw_lines: Vec::new(),
comments: Vec::new(),
}
}
pub fn from_path(path: &str) -> Result<Self> {
if !Path::new(path).exists() {
return Err(anyhow::anyhow!(".env file not found: {}", path));
}
let content = fs::read_to_string(path)?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self> {
let mut env_file = Self::new();
let mut current_key: Option<String> = None;
let mut multiline_value = String::new();
let re = Regex::new(r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$"#)?;
for (line_number, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
env_file.raw_lines.push(line.to_string());
continue;
}
if trimmed.starts_with('#') {
env_file.comments.push(CommentLine {
text: trimmed.to_string(),
line_number,
});
env_file.raw_lines.push(line.to_string());
continue;
}
if let Some(caps) = re.captures(trimmed) {
let key = caps[1].to_string();
let value = caps[2].to_string();
if value.ends_with('\\') && !value.ends_with("\\\\") {
multiline_value = value.trim_end_matches('\\').to_string() + "\n";
current_key = Some(key);
} else {
if let Some(prev_key) = current_key.take() {
multiline_value.push_str(&value);
let mut entry = EnvEntry::new(prev_key.clone(), multiline_value.clone(), line_number);
entry.is_multiline = true;
env_file.entries.insert(prev_key, entry);
multiline_value.clear();
} else {
let entry = EnvEntry::new(key.clone(), value.clone(), line_number);
env_file.entries.insert(key, entry);
}
}
} else if current_key.is_some() {
multiline_value.push_str(line);
multiline_value.push('\n');
} else {
env_file.raw_lines.push(line.to_string());
}
}
if let Some(prev_key) = current_key.take() {
let mut entry = EnvEntry::new(prev_key.clone(), multiline_value.clone(), 0);
entry.is_multiline = true;
env_file.entries.insert(prev_key, entry);
}
Ok(env_file)
}
pub fn get(&self, key: &str) -> Option<&EnvEntry> {
self.entries.get(key)
}
pub fn keys(&self) -> Vec<&String> {
self.entries.keys().collect()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn to_string(&self) -> String {
self.raw_lines.join("\n")
}
pub fn write_to_file(&self, path: &str) -> Result<()> {
let mut output = Vec::new();
for (key, entry) in &self.entries {
let line = format!("{}={}", key, entry.value);
output.push(line);
}
for comment in &self.comments {
output.insert(comment.line_number, comment.text.clone());
}
fs::write(path, output.join("\n"))?;
Ok(())
}
}
pub fn parse_dotenv(content: &str) -> Result<HashMap<String, String>> {
let env_file = EnvFile::parse(content)?;
let mut result = HashMap::new();
for (key, entry) in &env_file.entries {
result.insert(key.clone(), entry.unquoted_value());
}
Ok(result)
}
pub fn extract_key_value(line: &str) -> Option<(String, String)> {
let re = Regex::new(r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$"#).ok()?;
let caps = re.captures(line)?;
let key = caps.get(1)?.as_str().to_string();
let value = caps.get(2)?.as_str().to_string();
Some((key, value))
}

383
src/framework.rs Normal file
View File

@@ -0,0 +1,383 @@
use std::fs;
use std::path::Path;
use crate::config::EnvVarSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Framework {
NextJs,
Rails,
Django,
Express,
SpringBoot,
Laravel,
Flask,
ASPNET,
NestJS,
GoFiber,
Phoenix,
Unknown,
}
impl Framework {
pub fn detect(path: &str) -> Self {
let path = Path::new(path);
if path.join("next.config.js").exists() || path.join("next.config.mjs").exists() {
return Framework::NextJs;
}
if path.join("Gemfile").exists() && path.join("config.ru").exists() {
return Framework::Rails;
}
if path.join("manage.py").exists() && path.join("requirements.txt").exists() {
return Framework::Django;
}
if path.join("package.json").exists() {
let pkg_json = fs::read_to_string(path.join("package.json").to_string_lossy().as_ref());
if let Ok(content) = pkg_json {
if content.contains("\"next\"") || content.contains("\"next\":") {
return Framework::NextJs;
}
if content.contains("\"express\"") {
return Framework::Express;
}
if content.contains("\"@nestjs/core\"") {
return Framework::NestJS;
}
}
}
if path.join("composer.json").exists() && path.join("artisan").exists() {
return Framework::Laravel;
}
if path.join("pom.xml").exists() || path.join("build.gradle").exists() {
return Framework::SpringBoot;
}
if path.join("app.rb").exists() && path.join("config.ru").exists() {
return Framework::Rails;
}
if path.join("app.py").exists() && (path.join("requirements.txt").exists() || path.join("pyproject.toml").exists()) {
return Framework::Flask;
}
if path.join("go.mod").exists() {
let go_mod = fs::read_to_string(path.join("go.mod").to_string_lossy().as_ref());
if let Ok(content) = go_mod {
if content.contains("gofiber") || content.contains("fiber") {
return Framework::GoFiber;
}
}
}
if path.join("mix.exs").exists() {
return Framework::Phoenix;
}
Framework::Unknown
}
pub fn name(&self) -> &str {
match self {
Framework::NextJs => "Next.js",
Framework::Rails => "Ruby on Rails",
Framework::Django => "Django",
Framework::Express => "Express.js",
Framework::SpringBoot => "Spring Boot",
Framework::Laravel => "Laravel",
Framework::Flask => "Flask",
Framework::ASPNET => "ASP.NET",
Framework::NestJS => "NestJS",
Framework::GoFiber => "Go Fiber",
Framework::Phoenix => "Phoenix",
Framework::Unknown => "Unknown",
}
}
pub fn get_default_schema(&self) -> Vec<EnvVarSchema> {
match self {
Framework::NextJs => vec![
EnvVarSchema {
key: "NEXT_PUBLIC_API_URL".to_string(),
required: false,
r#type: Some("url".to_string()),
description: Some("Public API endpoint for client-side code".to_string()),
pattern: None,
default: Some("http://localhost:3000/api".to_string()),
},
EnvVarSchema {
key: "NEXTAUTH_SECRET".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Secret for NextAuth.js".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "NEXTAUTH_URL".to_string(),
required: true,
r#type: Some("url".to_string()),
description: Some("URL for NextAuth.js".to_string()),
pattern: None,
default: Some("http://localhost:3000".to_string()),
},
EnvVarSchema {
key: "DATABASE_URL".to_string(),
required: true,
r#type: Some("database_url".to_string()),
description: Some("PostgreSQL connection string".to_string()),
pattern: None,
default: None,
},
],
Framework::Rails => vec![
EnvVarSchema {
key: "DATABASE_URL".to_string(),
required: true,
r#type: Some("database_url".to_string()),
description: Some("PostgreSQL/MySQL connection string".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "SECRET_KEY_BASE".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Secret key for Rails".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "RAILS_ENV".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Rails environment (development, production, test)".to_string()),
pattern: None,
default: Some("development".to_string()),
},
EnvVarSchema {
key: "REDIS_URL".to_string(),
required: false,
r#type: Some("url".to_string()),
description: Some("Redis connection URL".to_string()),
pattern: None,
default: None,
},
],
Framework::Django => vec![
EnvVarSchema {
key: "SECRET_KEY".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Django secret key".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "DEBUG".to_string(),
required: false,
r#type: Some("boolean".to_string()),
description: Some("Enable debug mode".to_string()),
pattern: None,
default: Some("False".to_string()),
},
EnvVarSchema {
key: "ALLOWED_HOSTS".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Comma-separated allowed hosts".to_string()),
pattern: None,
default: Some("localhost,127.0.0.1".to_string()),
},
EnvVarSchema {
key: "DATABASE_URL".to_string(),
required: true,
r#type: Some("database_url".to_string()),
description: Some("PostgreSQL connection string".to_string()),
pattern: None,
default: None,
},
],
Framework::Express => vec![
EnvVarSchema {
key: "PORT".to_string(),
required: false,
r#type: Some("integer".to_string()),
description: Some("Port number".to_string()),
pattern: None,
default: Some("3000".to_string()),
},
EnvVarSchema {
key: "MONGODB_URI".to_string(),
required: false,
r#type: Some("url".to_string()),
description: Some("MongoDB connection string".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "JWT_SECRET".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("JWT signing secret".to_string()),
pattern: None,
default: None,
},
],
Framework::SpringBoot => vec![
EnvVarSchema {
key: "SPRING_DATASOURCE_URL".to_string(),
required: true,
r#type: Some("database_url".to_string()),
description: Some("Database connection URL".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "SPRING_DATASOURCE_USERNAME".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Database username".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "SPRING_DATASOURCE_PASSWORD".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Database password".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "SERVER_PORT".to_string(),
required: false,
r#type: Some("integer".to_string()),
description: Some("Server port".to_string()),
pattern: None,
default: Some("8080".to_string()),
},
],
Framework::Laravel => vec![
EnvVarSchema {
key: "APP_NAME".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Application name".to_string()),
pattern: None,
default: Some("Laravel".to_string()),
},
EnvVarSchema {
key: "APP_ENV".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Environment (local, production, etc.)".to_string()),
pattern: None,
default: Some("production".to_string()),
},
EnvVarSchema {
key: "APP_KEY".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Application encryption key".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "DB_CONNECTION".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Database driver (mysql, pgsql, sqlite)".to_string()),
pattern: None,
default: Some("mysql".to_string()),
},
],
Framework::Flask => vec![
EnvVarSchema {
key: "FLASK_APP".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Flask application module".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "FLASK_ENV".to_string(),
required: false,
r#type: Some("string".to_string()),
description: Some("Flask environment".to_string()),
pattern: None,
default: Some("development".to_string()),
},
EnvVarSchema {
key: "SECRET_KEY".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("Secret key for sessions".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "DATABASE_URL".to_string(),
required: false,
r#type: Some("database_url".to_string()),
description: Some("Database connection URL".to_string()),
pattern: None,
default: None,
},
],
Framework::NestJS => vec![
EnvVarSchema {
key: "PORT".to_string(),
required: false,
r#type: Some("integer".to_string()),
description: Some("Port number".to_string()),
pattern: None,
default: Some("3000".to_string()),
},
EnvVarSchema {
key: "DATABASE_URL".to_string(),
required: true,
r#type: Some("database_url".to_string()),
description: Some("PostgreSQL connection string".to_string()),
pattern: None,
default: None,
},
EnvVarSchema {
key: "JWT_SECRET".to_string(),
required: true,
r#type: Some("string".to_string()),
description: Some("JWT secret key".to_string()),
pattern: None,
default: None,
},
],
_ => Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FrameworkConfig {
pub name: String,
pub version: String,
pub detected: bool,
pub variables: Vec<EnvVarSchema>,
}
pub fn detect_framework(path: &str) -> FrameworkConfig {
let framework = Framework::detect(path);
let variables = framework.get_default_schema();
FrameworkConfig {
name: framework.name().to_string(),
version: String::new(),
detected: framework != Framework::Unknown,
variables,
}
}

6
src/lib.rs Normal file
View File

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

165
src/main.rs Normal file
View File

@@ -0,0 +1,165 @@
use clap::{Command, Arg};
use anyhow::Result;
mod config;
mod env_parser;
mod validation;
mod secrets;
mod framework;
mod commands;
use commands::{scan, validate, generate, secrets_cmd, init, check};
fn main() -> Result<()> {
let matches = Command::new("env-guard")
.version("0.1.0")
.about("Automatically detect, validate, and secure environment variables")
.subcommand_required(false)
.arg_required_else_help(true)
.subcommand(
Command::new("scan")
.about("Scan .env files and compare against expected variables")
.arg(
Arg::new("path")
.short('p')
.long("path")
.value_name("PATH")
.help("Path to scan for .env files")
.default_value(".")
)
.arg(
Arg::new("schema")
.short('s')
.long("schema")
.value_name("FILE")
.help("Path to schema file (.env.schema.json)")
)
)
.subcommand(
Command::new("validate")
.about("Validate format of environment variable values")
.arg(
Arg::new("path")
.short('p')
.long("path")
.value_name("FILE")
.help("Path to .env file")
.default_value(".env")
)
.arg(
Arg::new("strict")
.short('S')
.long("strict")
.help("Enable strict validation")
.action(clap::ArgAction::SetTrue)
)
)
.subcommand(
Command::new("generate")
.about("Generate .env.example file from .env")
.arg(
Arg::new("path")
.short('p')
.long("path")
.value_name("FILE")
.help("Path to .env file")
.default_value(".env")
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.value_name("FILE")
.help("Output file path")
.default_value(".env.example")
)
)
.subcommand(
Command::new("secrets")
.about("Scan source code for accidentally committed secrets")
.arg(
Arg::new("path")
.short('p')
.long("path")
.value_name("PATH")
.help("Path to scan for secrets")
.default_value(".")
)
.arg(
Arg::new("strict")
.short('S')
.long("strict")
.help("Enable strict secret detection")
.action(clap::ArgAction::SetTrue)
)
)
.subcommand(
Command::new("init")
.about("Initialize env-guard with framework detection")
.arg(
Arg::new("framework")
.short('f')
.long("framework")
.value_name("FRAMEWORK")
.help("Framework to use (nextjs, rails, django, node)")
)
.arg(
Arg::new("path")
.short('p')
.long("path")
.value_name("PATH")
.help("Path to project directory")
.default_value(".")
)
)
.subcommand(
Command::new("check")
.about("Check .env file for common issues")
.arg(
Arg::new("path")
.short('p')
.long("path")
.value_name("FILE")
.help("Path to .env file")
.default_value(".env")
)
)
.get_matches();
match matches.subcommand() {
Some(("scan", sub_matches)) => {
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str()).unwrap_or(".");
let schema = sub_matches.get_one::<String>("schema").map(|s| s.as_str());
scan(path, schema)?;
}
Some(("validate", sub_matches)) => {
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str()).unwrap_or(".env");
let strict = sub_matches.get_flag("strict");
validate(path, strict)?;
}
Some(("generate", sub_matches)) => {
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str()).unwrap_or(".env");
let output = sub_matches.get_one::<String>("output").map(|s| s.as_str());
generate(path, output)?;
}
Some(("secrets", sub_matches)) => {
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str()).unwrap_or(".");
let strict = sub_matches.get_flag("strict");
secrets_cmd(path, strict)?;
}
Some(("init", sub_matches)) => {
let framework = sub_matches.get_one::<String>("framework").map(|s| s.as_str());
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str());
init(framework, path)?;
}
Some(("check", sub_matches)) => {
let path = sub_matches.get_one::<String>("path").map(|s| s.as_str()).unwrap_or(".env");
check(path)?;
}
_ => {
let _ = Command::new("env-guard").print_help();
}
}
Ok(())
}

257
src/secrets.rs Normal file
View File

@@ -0,0 +1,257 @@
use regex::Regex;
use std::fs;
use std::path::Path;
use thiserror::Error;
use anyhow::Result;
#[derive(Debug, Error)]
pub enum SecretError {
#[error("Secret found: {0}")]
SecretFound(String),
#[error("Scan error: {0}")]
ScanError(String),
}
#[derive(Debug, Clone)]
pub struct SecretMatch {
pub file: String,
pub line_number: usize,
pub line_content: String,
pub secret_type: String,
pub severity: SecretSeverity,
pub recommendation: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SecretSeverity {
Critical,
High,
Medium,
Low,
}
impl SecretSeverity {
pub fn as_str(&self) -> &str {
match self {
SecretSeverity::Critical => "CRITICAL",
SecretSeverity::High => "HIGH",
SecretSeverity::Medium => "MEDIUM",
SecretSeverity::Low => "LOW",
}
}
}
#[derive(Debug, Clone)]
pub struct SecretPattern {
pub name: String,
pub pattern: Regex,
pub severity: SecretSeverity,
pub recommendation: String,
pub example: String,
}
pub fn get_builtin_patterns() -> Vec<SecretPattern> {
vec![
SecretPattern {
name: "AWS Access Key ID".to_string(),
pattern: Regex::new(r"(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Rotate this AWS access key immediately and remove from code".to_string(),
example: "AKIAIOSFODNN7EXAMPLE".to_string(),
},
SecretPattern {
name: "AWS Secret Access Key".to_string(),
pattern: Regex::new(r"(?i)aws_secret_access_key\s*=\s*[\w/+]{40}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Rotate this AWS secret key and use environment variables".to_string(),
example: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
},
SecretPattern {
name: "GitHub Personal Access Token".to_string(),
pattern: Regex::new(r"ghp_[A-Za-z0-9_]{36,}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Revoke this GitHub token and use a new one".to_string(),
example: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
},
SecretPattern {
name: "GitHub OAuth Token".to_string(),
pattern: Regex::new(r"gho_[A-Za-z0-9_]{36,}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Revoke this GitHub OAuth token".to_string(),
example: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
},
SecretPattern {
name: "GitHub App Token".to_string(),
pattern: Regex::new(r"(ghu|ghs)_[A-Za-z0-9_]{36,}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Revoke this GitHub app token".to_string(),
example: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
},
SecretPattern {
name: "Slack Bot Token".to_string(),
pattern: Regex::new(r"xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}").unwrap(),
severity: SecretSeverity::High,
recommendation: "Rotate this Slack bot token".to_string(),
example: "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx".to_string(),
},
SecretPattern {
name: "Slack Webhook URL".to_string(),
pattern: Regex::new(r"https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+").unwrap(),
severity: SecretSeverity::Medium,
recommendation: "Consider rotating if sensitive information was shared".to_string(),
example: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
},
SecretPattern {
name: "Private Key".to_string(),
pattern: Regex::new(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Remove private key from code and use external storage".to_string(),
example: "-----BEGIN RSA PRIVATE KEY-----".to_string(),
},
SecretPattern {
name: "JWT Token".to_string(),
pattern: Regex::new(r"eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*").unwrap(),
severity: SecretSeverity::High,
recommendation: "Check if this JWT contains sensitive information".to_string(),
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(),
},
SecretPattern {
name: "OpenAI API Key".to_string(),
pattern: Regex::new(r"sk-[A-Za-z0-9]{48}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Rotate this OpenAI API key immediately".to_string(),
example: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
},
SecretPattern {
name: "Stripe API Key".to_string(),
pattern: Regex::new(r"(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{24,}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Rotate this Stripe API key and use test keys in development".to_string(),
example: "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
},
SecretPattern {
name: "Heroku API Key".to_string(),
pattern: Regex::new(r"(?i)heroku[_-]api[_-]key\s*[:=]\s*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}").unwrap(),
severity: SecretSeverity::Critical,
recommendation: "Revoke this Heroku API key".to_string(),
example: "HEROKU_API_KEY = 01234567-89ab-cdef-0123-456789abcdef".to_string(),
},
SecretPattern {
name: "Google API Key".to_string(),
pattern: Regex::new(r"AIza[0-9A-Za-z\\-_]{35}").unwrap(),
severity: SecretSeverity::High,
recommendation: "Restrict this Google API key to your domain".to_string(),
example: "AIzaSyDaC4eD6KdXy3XyZEx4XlX3XZ3XlX3XZ3X".to_string(),
},
]
}
pub fn scan_file(file_path: &str, strict: bool) -> Result<Vec<SecretMatch>> {
let content = fs::read_to_string(file_path)?;
let patterns = get_builtin_patterns();
let mut matches = Vec::new();
for (line_number, line) in content.lines().enumerate() {
for pattern in &patterns {
if pattern.pattern.is_match(line) {
if strict || pattern.severity != SecretSeverity::Low {
matches.push(SecretMatch {
file: file_path.to_string(),
line_number: line_number + 1,
line_content: line.to_string(),
secret_type: pattern.name.clone(),
severity: pattern.severity.clone(),
recommendation: pattern.recommendation.clone(),
});
}
}
}
}
Ok(matches)
}
pub fn scan_directory(path: &str, strict: bool, ignore_patterns: Option<&[String]>) -> Result<Vec<SecretMatch>> {
let mut all_matches = Vec::new();
let patterns = get_builtin_patterns();
let ignore_set: Vec<String> = ignore_patterns
.map(|v| v.iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
fn is_ignored(path: &str, ignore_patterns: &[String]) -> bool {
let path_lower = path.to_lowercase();
for pattern in ignore_patterns {
if path_lower.contains(&pattern.to_lowercase()) {
return true;
}
}
false
}
fn scan_dir_recursive(
dir: &Path,
patterns: &[SecretPattern],
all_matches: &mut Vec<SecretMatch>,
strict: bool,
ignore_patterns: &[String],
) -> Result<()> {
if is_ignored(&dir.to_string_lossy(), ignore_patterns) {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if is_ignored(&path.to_string_lossy(), ignore_patterns) {
continue;
}
if path.is_dir() {
if path.file_name().map(|n| n.to_string_lossy()) != Some("node_modules".into())
&& path.file_name().map(|n| n.to_string_lossy()) != Some(".git".into())
{
scan_dir_recursive(&path, patterns, all_matches, strict, ignore_patterns)?;
}
} else if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
let scanable = [
"js", "ts", "py", "rb", "go", "java", "c", "cpp", "h", "rs",
"php", "swift", "kt", "scala", "r", "sql", "json", "yaml", "yml",
"xml", "env", "ini", "cfg", "conf", "txt", "md",
];
if scanable.contains(&ext_str.as_str()) || path.file_name().map(|n| n.to_string_lossy()) == Some(".env".into()) {
if let Ok(file_matches) = scan_file(&path.to_string_lossy(), strict) {
all_matches.extend(file_matches);
}
}
}
}
Ok(())
}
scan_dir_recursive(Path::new(path), &patterns, &mut all_matches, strict, &ignore_set)?;
Ok(all_matches)
}
pub fn redact_secret(value: &str) -> String {
if value.len() <= 8 {
return "*".repeat(value.len());
}
let visible = 4;
let hidden = value.len() - visible;
format!("{}{}", &value[..visible], "*".repeat(hidden.min(40)))
}
pub fn format_secret_match(match_item: &SecretMatch) -> String {
format!(
"[{}] {} (line {}): {}\n -> {}",
match_item.severity.as_str(),
match_item.secret_type,
match_item.line_number,
redact_secret(&match_item.line_content),
match_item.recommendation
)
}

321
src/validation.rs Normal file
View File

@@ -0,0 +1,321 @@
use regex::Regex;
use std::collections::HashMap;
use thiserror::Error;
use anyhow::Result;
#[derive(Debug, Error, Clone)]
pub enum ValidationError {
#[error("Missing required variable: {0}")]
MissingVariable(String),
#[error("Invalid format for {0}: {1}")]
InvalidFormat(String, String),
#[error("Empty value for required variable: {0}")]
EmptyValue(String),
#[error("Unknown validation type: {0}")]
UnknownType(String),
}
#[derive(Debug, Clone)]
pub struct ValidationRule {
pub key_pattern: String,
pub r#type: ValidationType,
pub required: bool,
pub description: String,
}
#[derive(Debug, Clone)]
pub enum ValidationType {
Url,
Email,
Uuid,
ApiKey,
Boolean,
Integer,
DatabaseUrl,
Jwt,
AwsKey,
GitHubToken,
SlackWebhook,
Custom(Regex),
}
impl ValidationType {
pub fn validate(&self, value: &str) -> bool {
match self {
ValidationType::Url => validate_url(value),
ValidationType::Email => validate_email(value),
ValidationType::Uuid => validate_uuid(value),
ValidationType::ApiKey => validate_api_key(value),
ValidationType::Boolean => validate_boolean(value),
ValidationType::Integer => validate_integer(value),
ValidationType::DatabaseUrl => validate_database_url(value),
ValidationType::Jwt => validate_jwt(value),
ValidationType::AwsKey => validate_aws_key(value),
ValidationType::GitHubToken => validate_github_token(value),
ValidationType::SlackWebhook => validate_slack_webhook(value),
ValidationType::Custom(re) => re.is_match(value),
}
}
pub fn from_string(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"url" => Some(ValidationType::Url),
"email" => Some(ValidationType::Email),
"uuid" => Some(ValidationType::Uuid),
"apikey" | "api_key" => Some(ValidationType::ApiKey),
"boolean" | "bool" => Some(ValidationType::Boolean),
"integer" | "int" => Some(ValidationType::Integer),
"database" | "database_url" => Some(ValidationType::DatabaseUrl),
"jwt" => Some(ValidationType::Jwt),
"aws" | "aws_key" => Some(ValidationType::AwsKey),
"github" | "github_token" => Some(ValidationType::GitHubToken),
"slack" | "slack_webhook" => Some(ValidationType::SlackWebhook),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ValidationResult {
pub passed: Vec<String>,
pub failed: Vec<ValidationFailure>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ValidationFailure {
pub key: String,
pub error: ValidationError,
pub value: Option<String>,
}
pub fn validate_url(value: &str) -> bool {
let url_pattern = Regex::new(r#"^(https?)://[^\s/$.?#].[^\s]*$"#).unwrap();
url_pattern.is_match(value.trim())
}
pub fn validate_email(value: &str) -> bool {
let email_pattern = Regex::new(r#"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"#).unwrap();
email_pattern.is_match(value.trim())
}
pub fn validate_uuid(value: &str) -> bool {
let uuid_pattern = Regex::new(r#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#).unwrap();
uuid_pattern.is_match(value.trim())
}
pub fn validate_api_key(value: &str) -> bool {
if value.len() < 16 {
return false;
}
let api_key_patterns = [
Regex::new(r#"^sk-[a-zA-Z0-9]{20,}$"#).unwrap(),
Regex::new(r#"^pk_[a-zA-Z0-9]{20,}$"#).unwrap(),
Regex::new(r#"^[A-Za-z0-9-_]{32,}$"#).unwrap(),
];
api_key_patterns.iter().any(|p| p.is_match(value))
}
pub fn validate_boolean(value: &str) -> bool {
let v = value.to_lowercase();
matches!(v.as_str(), "true" | "false" | "1" | "0" | "yes" | "no")
}
pub fn validate_integer(value: &str) -> bool {
value.trim().parse::<i64>().is_ok()
}
pub fn validate_database_url(value: &str) -> bool {
let db_patterns = [
Regex::new(r#"^postgres://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+/[^\s]+$"#).unwrap(),
Regex::new(r#"^postgresql://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+/[^\s]+$"#).unwrap(),
Regex::new(r#"^mysql://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+/[^\s]+$"#).unwrap(),
Regex::new(r#"^mongodb(\+srv)?://[^\s@]+:[^\s@]+@[^\s@]+/[^\s]+$"#).unwrap(),
Regex::new(r#"^redis://[^\s@]+:[^\s@]+@[^\s@]+:[0-9]+$"#).unwrap(),
Regex::new(r#"^sqlite:///.*\.db$"#).unwrap(),
];
db_patterns.iter().any(|p| p.is_match(value.trim()))
}
pub fn validate_jwt(value: &str) -> bool {
let jwt_pattern = Regex::new(r#"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$"#).unwrap();
jwt_pattern.is_match(value.trim())
}
pub fn validate_aws_key(value: &str) -> bool {
let aws_patterns = [
Regex::new(r#"^AKIA[0-9A-Z]{16}$"#).unwrap(),
Regex::new(r#"^aws_access_key_id\s*=\s*[A-Z0-9]{20}$"#).unwrap(),
];
aws_patterns.iter().any(|p| p.is_match(value))
}
pub fn validate_github_token(value: &str) -> bool {
let github_patterns = [
Regex::new(r#"^gh[pousr]_[A-Za-z0-9_]{36,}$"#).unwrap(),
Regex::new(r#"^github_pat_[A-Za-z0-9_]{22,}$"#).unwrap(),
];
github_patterns.iter().any(|p| p.is_match(value.trim()))
}
pub fn validate_slack_webhook(value: &str) -> bool {
let slack_pattern = Regex::new(r#"^https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+$"#).unwrap();
slack_pattern.is_match(value.trim())
}
pub fn infer_type(key: &str, _value: &str) -> Option<ValidationType> {
let key_lower = key.to_lowercase();
if key_lower.contains("url") || key_lower.contains("uri") {
if key_lower.contains("database") || key_lower.contains("db_") {
return Some(ValidationType::DatabaseUrl);
}
return Some(ValidationType::Url);
}
if key_lower.contains("email") || key_lower.ends_with("_mail") {
return Some(ValidationType::Email);
}
if key_lower.contains("uuid") || key_lower == "id" {
return Some(ValidationType::Uuid);
}
if key_lower.contains("secret") || key_lower.contains("key") || key_lower.contains("token") {
if key_lower.contains("aws") {
return Some(ValidationType::AwsKey);
}
if key_lower.contains("github") {
return Some(ValidationType::GitHubToken);
}
if key_lower.contains("jwt") || key_lower.contains("bearer") {
return Some(ValidationType::Jwt);
}
return Some(ValidationType::ApiKey);
}
if key_lower.contains("boolean") || key_lower.contains("enabled") || key_lower.contains("active") {
return Some(ValidationType::Boolean);
}
if key_lower.contains("port") || key_lower.contains("timeout") || key_lower.contains("retry") {
return Some(ValidationType::Integer);
}
if key_lower.contains("slack") || key_lower.contains("webhook") {
return Some(ValidationType::SlackWebhook);
}
None
}
pub fn validate_value(key: &str, value: &str, expected_type: Option<&ValidationType>) -> Result<(), ValidationError> {
if value.trim().is_empty() {
return Err(ValidationError::EmptyValue(key.to_string()));
}
if let Some(validator) = expected_type {
if !validator.validate(value) {
return Err(ValidationError::InvalidFormat(
key.to_string(),
format!("{:?}", validator),
));
}
}
Ok(())
}
pub struct Validator {
pub rules: Vec<ValidationRule>,
}
impl Validator {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn with_builtin_rules() -> Self {
let mut rules = Vec::new();
rules.push(ValidationRule {
key_pattern: ".*_URL".to_string(),
r#type: ValidationType::Url,
required: false,
description: "URL format validation".to_string(),
});
rules.push(ValidationRule {
key_pattern: ".*_EMAIL".to_string(),
r#type: ValidationType::Email,
required: false,
description: "Email format validation".to_string(),
});
rules.push(ValidationRule {
key_pattern: "DATABASE_URL".to_string(),
r#type: ValidationType::DatabaseUrl,
required: true,
description: "Database connection URL".to_string(),
});
rules.push(ValidationRule {
key_pattern: ".*_API_KEY".to_string(),
r#type: ValidationType::ApiKey,
required: false,
description: "API key format".to_string(),
});
rules.push(ValidationRule {
key_pattern: "AWS_ACCESS_KEY.*".to_string(),
r#type: ValidationType::AwsKey,
required: false,
description: "AWS access key format".to_string(),
});
Self { rules }
}
pub fn validate(&self, key: &str, value: &str, required: bool) -> Result<(), ValidationError> {
if value.trim().is_empty() {
if required {
return Err(ValidationError::MissingVariable(key.to_string()));
}
return Ok(());
}
for rule in &self.rules {
if regex::Regex::new(&rule.key_pattern)
.unwrap()
.is_match(key)
{
if !rule.r#type.validate(value) {
return Err(ValidationError::InvalidFormat(
key.to_string(),
rule.description.clone(),
));
}
}
}
Ok(())
}
pub fn validate_all(&self, variables: &HashMap<String, String>) -> ValidationResult {
let mut result = ValidationResult::default();
for (key, value) in variables {
if let Err(e) = self.validate(key, value, true) {
result.failed.push(ValidationFailure {
key: key.clone(),
error: e,
value: Some(value.clone()),
});
} else {
result.passed.push(key.clone());
}
}
result
}
}