use super::types::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComplianceFinding {
pub rule_id: String,
pub benchmark: String,
pub severity: FindingSeverity,
pub resource_id: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingSeverity {
Critical,
High,
Medium,
Low,
Info,
}
pub fn evaluate_benchmark(benchmark: &str, config: &ForjarConfig) -> Vec<ComplianceFinding> {
match benchmark.to_lowercase().as_str() {
"cis" => evaluate_cis(config),
"nist" | "nist-800-53" => evaluate_nist_800_53(config),
"soc2" => evaluate_soc2(config),
"hipaa" => evaluate_hipaa(config),
_ => vec![ComplianceFinding {
rule_id: "UNKNOWN-1".to_string(),
benchmark: benchmark.to_string(),
severity: FindingSeverity::Info,
resource_id: String::new(),
message: format!("unknown benchmark: {benchmark}"),
}],
}
}
pub fn supported_benchmarks() -> &'static [&'static str] {
&["cis", "nist-800-53", "soc2", "hipaa"]
}
fn evaluate_cis(config: &ForjarConfig) -> Vec<ComplianceFinding> {
let mut findings = Vec::new();
for (id, resource) in &config.resources {
cis_world_writable(&mut findings, id, resource);
cis_root_tmp(&mut findings, id, resource);
cis_service_no_restart_policy(&mut findings, id, resource);
cis_package_no_version_pin(&mut findings, id, resource);
}
findings
}
fn cis_world_writable(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if let Some(ref mode) = r.mode {
if mode.ends_with('7') || mode.ends_with('6') {
findings.push(ComplianceFinding {
rule_id: "CIS-6.1.1".to_string(),
benchmark: "cis".to_string(),
severity: FindingSeverity::High,
resource_id: id.to_string(),
message: format!("world-writable mode {mode}"),
});
}
}
}
fn cis_root_tmp(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if let Some(ref path) = r.path {
if path.starts_with("/tmp") && r.owner.as_deref() == Some("root") {
findings.push(ComplianceFinding {
rule_id: "CIS-1.1.5".to_string(),
benchmark: "cis".to_string(),
severity: FindingSeverity::Medium,
resource_id: id.to_string(),
message: "root-owned file in /tmp".to_string(),
});
}
}
}
fn cis_service_no_restart_policy(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::Service && r.restart.is_none() {
findings.push(ComplianceFinding {
rule_id: "CIS-5.2.1".to_string(),
benchmark: "cis".to_string(),
severity: FindingSeverity::Low,
resource_id: id.to_string(),
message: "service has no restart policy".to_string(),
});
}
}
fn cis_package_no_version_pin(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::Package && r.version.is_none() {
findings.push(ComplianceFinding {
rule_id: "CIS-6.2.1".to_string(),
benchmark: "cis".to_string(),
severity: FindingSeverity::Low,
resource_id: id.to_string(),
message: "package has no version pin".to_string(),
});
}
}
fn evaluate_nist_800_53(config: &ForjarConfig) -> Vec<ComplianceFinding> {
let mut findings = Vec::new();
for (id, resource) in &config.resources {
nist_ac3_access_enforcement(&mut findings, id, resource);
nist_ac6_least_privilege(&mut findings, id, resource);
nist_cm6_config_settings(&mut findings, id, resource);
nist_sc28_protection_at_rest(&mut findings, id, resource);
nist_si7_integrity_verification(&mut findings, id, resource, config);
}
findings
}
fn nist_ac3_access_enforcement(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type != ResourceType::File {
return;
}
if r.owner.is_none() {
findings.push(ComplianceFinding {
rule_id: "NIST-AC-3.1".to_string(),
benchmark: "nist-800-53".to_string(),
severity: FindingSeverity::Medium,
resource_id: id.to_string(),
message: "file resource missing owner (AC-3 access enforcement)".to_string(),
});
}
if r.mode.is_none() {
findings.push(ComplianceFinding {
rule_id: "NIST-AC-3.2".to_string(),
benchmark: "nist-800-53".to_string(),
severity: FindingSeverity::Medium,
resource_id: id.to_string(),
message: "file resource missing mode (AC-3 access enforcement)".to_string(),
});
}
}
fn nist_ac6_least_privilege(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::Service && r.owner.as_deref() == Some("root") {
findings.push(ComplianceFinding {
rule_id: "NIST-AC-6".to_string(),
benchmark: "nist-800-53".to_string(),
severity: FindingSeverity::High,
resource_id: id.to_string(),
message: "service running as root violates least privilege (AC-6)".to_string(),
});
}
}
fn nist_cm6_config_settings(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::Docker && r.ports.is_empty() && r.port.is_none() {
findings.push(ComplianceFinding {
rule_id: "NIST-CM-6".to_string(),
benchmark: "nist-800-53".to_string(),
severity: FindingSeverity::Low,
resource_id: id.to_string(),
message: "Docker resource has no explicit port bindings (CM-6)".to_string(),
});
}
}
fn nist_sc28_protection_at_rest(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type != ResourceType::File {
return;
}
let sensitive_paths = ["/etc/shadow", "/etc/ssh", "/etc/ssl", "/etc/pki"];
if let Some(ref path) = r.path {
if sensitive_paths.iter().any(|p| path.starts_with(p)) && r.mode.is_none() {
findings.push(ComplianceFinding {
rule_id: "NIST-SC-28".to_string(),
benchmark: "nist-800-53".to_string(),
severity: FindingSeverity::High,
resource_id: id.to_string(),
message: format!("sensitive path {path} missing explicit mode (SC-28)"),
});
}
}
}
fn nist_si7_integrity_verification(
findings: &mut Vec<ComplianceFinding>,
id: &str,
r: &Resource,
config: &ForjarConfig,
) {
if r.resource_type != ResourceType::File || r.source.is_none() {
return;
}
let has_check = config.checks.contains_key(id);
if !has_check {
findings.push(ComplianceFinding {
rule_id: "NIST-SI-7".to_string(),
benchmark: "nist-800-53".to_string(),
severity: FindingSeverity::Medium,
resource_id: id.to_string(),
message: "externally-sourced file has no integrity check block (SI-7)".to_string(),
});
}
}
fn evaluate_soc2(config: &ForjarConfig) -> Vec<ComplianceFinding> {
let mut findings = Vec::new();
for (id, resource) in &config.resources {
soc2_file_ownership(&mut findings, id, resource);
soc2_service_monitoring(&mut findings, id, resource);
}
findings
}
fn soc2_file_ownership(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::File && r.owner.is_none() {
findings.push(ComplianceFinding {
rule_id: "SOC2-CC6.1".to_string(),
benchmark: "soc2".to_string(),
severity: FindingSeverity::Medium,
resource_id: id.to_string(),
message: "file resource missing owner (CC6.1 logical access)".to_string(),
});
}
}
fn soc2_service_monitoring(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::Service && r.restart_on.is_empty() {
findings.push(ComplianceFinding {
rule_id: "SOC2-CC7.2".to_string(),
benchmark: "soc2".to_string(),
severity: FindingSeverity::Low,
resource_id: id.to_string(),
message: "service has no restart_on triggers (CC7.2 monitoring)".to_string(),
});
}
}
fn evaluate_hipaa(config: &ForjarConfig) -> Vec<ComplianceFinding> {
let mut findings = Vec::new();
for (id, resource) in &config.resources {
hipaa_file_permissions(&mut findings, id, resource);
hipaa_network_encryption(&mut findings, id, resource);
}
findings
}
fn hipaa_file_permissions(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if let Some(ref mode) = r.mode {
let last = mode.chars().last().unwrap_or('0');
if last != '0' {
findings.push(ComplianceFinding {
rule_id: "HIPAA-164.312a".to_string(),
benchmark: "hipaa".to_string(),
severity: FindingSeverity::High,
resource_id: id.to_string(),
message: format!("mode {mode} allows other access (164.312(a) access control)"),
});
}
}
}
fn hipaa_network_encryption(findings: &mut Vec<ComplianceFinding>, id: &str, r: &Resource) {
if r.resource_type == ResourceType::Network {
if let Some(ref port) = r.port {
if port == "80" || port == "8080" {
findings.push(ComplianceFinding {
rule_id: "HIPAA-164.312e".to_string(),
benchmark: "hipaa".to_string(),
severity: FindingSeverity::Critical,
resource_id: id.to_string(),
message: format!("unencrypted port {port} (164.312(e) transmission security)"),
});
}
}
}
}
pub fn count_by_severity(findings: &[ComplianceFinding]) -> (usize, usize, usize, usize) {
let mut critical = 0;
let mut high = 0;
let mut medium = 0;
let mut low = 0;
for f in findings {
match f.severity {
FindingSeverity::Critical => critical += 1,
FindingSeverity::High => high += 1,
FindingSeverity::Medium => medium += 1,
FindingSeverity::Low | FindingSeverity::Info => low += 1,
}
}
(critical, high, medium, low)
}