Initial commit: env-guard CLI tool with CI/CD
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user