use crate::terraform::model::core::TerraformAnalysis;
use crate::terraform::model::validation::{
CountUsageWarning, GuidelineCheckResult, SecretDetection,
};
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
static ANY_TYPE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"type\s*=\s*any\b"#).expect("Invalid any type regex"));
static COUNT_VALUE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"resource\s+"([^"]+)"\s+"([^"]+)"\s*\{[^}]*count\s*=\s*([^\n]+)"#)
.expect("Invalid count value regex")
});
static DEFAULT_TAGS_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"default_tags\s*\{"#).expect("Invalid default_tags regex"));
static LIFECYCLE_PREVENT_DESTROY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"lifecycle\s*\{[^}]*prevent_destroy\s*=\s*true"#).expect("Invalid lifecycle regex")
});
static SECRET_PATTERNS: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
vec![
(
"AWS Access Key",
Regex::new(r#"(?i)(aws_access_key_id|access_key)\s*=\s*"[A-Z0-9]{20}""#)
.expect("Invalid AWS access key regex"),
),
(
"AWS Secret Key",
Regex::new(r#"(?i)(aws_secret_access_key|secret_key)\s*=\s*"[A-Za-z0-9/+=]{40}""#)
.expect("Invalid AWS secret key regex"),
),
(
"Generic API Key",
Regex::new(r#"(?i)(api_key|apikey)\s*=\s*"[A-Za-z0-9_-]{20,}""#)
.expect("Invalid API key regex"),
),
(
"Generic Secret",
Regex::new(r#"(?i)(password|secret|token)\s*=\s*"[^"]{8,}""#)
.expect("Invalid secret regex"),
),
(
"Private Key",
Regex::new(r#"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"#)
.expect("Invalid private key regex"),
),
]
});
static CRITICAL_RESOURCE_TYPES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"aws_db_instance",
"aws_rds_cluster",
"aws_s3_bucket",
"aws_dynamodb_table",
"aws_elasticsearch_domain",
"aws_elasticache_cluster",
"aws_kms_key",
"google_sql_database_instance",
"google_storage_bucket",
"azurerm_sql_database",
"azurerm_storage_account",
]
.into_iter()
.collect()
});
pub fn check_guidelines(
analysis: &TerraformAnalysis,
file_contents: &HashMap<String, String>,
) -> GuidelineCheckResult {
let mut result = GuidelineCheckResult::default();
for var in &analysis.variables {
if var.type_.is_none() {
result.variables_missing_type.push(var.name.clone());
}
}
for var in &analysis.variables {
if var.description.is_none()
|| var
.description
.as_ref()
.is_some_and(|d| d.trim().is_empty())
{
result.variables_missing_description.push(var.name.clone());
}
}
for output in &analysis.outputs {
if output.description.is_none()
|| output
.description
.as_ref()
.is_some_and(|d| d.trim().is_empty())
{
result.outputs_missing_description.push(output.name.clone());
}
}
for provider in &analysis.providers {
if provider.version.is_none() {
result.providers_missing_version.push(provider.name.clone());
}
}
let mut has_aws_provider = false;
let mut has_default_tags = false;
let mut resources_with_lifecycle: HashSet<String> = HashSet::new();
for (filename, content) in file_contents {
if content.contains("provider \"aws\"") || content.contains("aws_") {
has_aws_provider = true;
}
if DEFAULT_TAGS_REGEX.is_match(content) {
has_default_tags = true;
}
if ANY_TYPE_REGEX.is_match(content) {
for line in content.lines() {
if line.contains("type") && line.contains("any") {
if let Some(start) = content.find("variable \"") {
if let Some(end) = content[start + 10..].find('"') {
let var_name = &content[start + 10..start + 10 + end];
if !result.any_type_usage.contains(&var_name.to_string()) {
result.any_type_usage.push(var_name.to_string());
}
}
}
}
}
}
check_count_usage(content, filename, &mut result.count_instead_of_foreach);
check_secrets(content, filename, &mut result.hardcoded_secrets);
if LIFECYCLE_PREVENT_DESTROY_REGEX.is_match(content) {
for resource in &analysis.resources {
if content.contains(&format!(
"resource \"{}\" \"{}\"",
resource.resource_type, resource.name
)) {
resources_with_lifecycle
.insert(format!("{}.{}", resource.resource_type, resource.name));
}
}
}
}
result.missing_default_tags = has_aws_provider && !has_default_tags;
for resource in &analysis.resources {
if CRITICAL_RESOURCE_TYPES.contains(resource.resource_type.as_str()) {
let resource_id = format!("{}.{}", resource.resource_type, resource.name);
if !resources_with_lifecycle.contains(&resource_id) {
result.missing_lifecycle_protection.push(resource_id);
}
}
}
result.compliance_score = calculate_compliance_score(&result, analysis);
result
}
fn check_count_usage(content: &str, filename: &str, warnings: &mut Vec<CountUsageWarning>) {
for cap in COUNT_VALUE_REGEX.captures_iter(content) {
let resource_type = cap.get(1).map(|m| m.as_str()).unwrap_or("");
let resource_name = cap.get(2).map(|m| m.as_str()).unwrap_or("");
let count_value = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim();
let is_toggle = count_value == "0"
|| count_value == "1"
|| count_value.contains("? 1 : 0")
|| count_value.contains("? 0 : 1")
|| count_value.starts_with("var.enable_")
|| count_value.starts_with("var.create_")
|| count_value.starts_with("local.enable_")
|| count_value.starts_with("local.create_");
if !is_toggle {
warnings.push(CountUsageWarning {
resource_name: format!("{resource_type}.{resource_name}"),
resource_type: resource_type.to_string(),
suggestion: format!(
"Consider using for_each instead of count in {resource_name} (file: {filename}). \
for_each provides stable resource addresses when items are added/removed."
),
});
}
}
}
fn check_secrets(content: &str, filename: &str, detections: &mut Vec<SecretDetection>) {
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.starts_with("//") {
continue;
}
for (pattern_name, regex) in SECRET_PATTERNS.iter() {
if regex.is_match(line) {
detections.push(SecretDetection {
file: filename.to_string(),
line: line_num + 1,
pattern: pattern_name.to_string(),
severity: if *pattern_name == "Private Key" || pattern_name.contains("Secret") {
"critical".to_string()
} else {
"high".to_string()
},
});
}
}
}
}
fn calculate_compliance_score(result: &GuidelineCheckResult, analysis: &TerraformAnalysis) -> u8 {
let mut score: i32 = 100;
let type_penalty = (result.variables_missing_type.len() as i32 * 3).min(15);
score -= type_penalty;
let var_desc_penalty = (result.variables_missing_description.len() as i32 * 2).min(10);
score -= var_desc_penalty;
let out_desc_penalty = (result.outputs_missing_description.len() as i32 * 2).min(10);
score -= out_desc_penalty;
let count_penalty = (result.count_instead_of_foreach.len() as i32 * 5).min(15);
score -= count_penalty;
let any_penalty = (result.any_type_usage.len() as i32 * 5).min(10);
score -= any_penalty;
let version_penalty = (result.providers_missing_version.len() as i32 * 5).min(10);
score -= version_penalty;
if result.missing_default_tags && !analysis.providers.is_empty() {
score -= 10;
}
let secret_penalty = (result.hardcoded_secrets.len() as i32 * 20).min(40);
score -= secret_penalty;
let lifecycle_penalty = (result.missing_lifecycle_protection.len() as i32 * 5).min(15);
score -= lifecycle_penalty;
score.clamp(0, 100) as u8
}