kavach 1.0.1

Sandbox execution framework — backend abstraction, strength scoring, policy engine, credential proxy, and audit hooks
Documentation
//! Externalization gate — multi-stage scanning pipeline for sandbox output.
//!
//! Runs secrets, code, and data scanners concurrently, aggregates findings,
//! and applies the externalization policy verdict.

use super::code::CodeScanner;
use super::data::DataScanner;
use super::secrets::SecretsScanner;
use super::types::{ExternalizationPolicy, ScanResult, ScanVerdict, Severity};
use crate::lifecycle::ExecResult;

/// The externalization gate wraps sandbox results and applies content policy.
///
/// Runs three scanners on every output:
/// 1. **Secrets** — credentials, API keys, private keys, connection strings
/// 2. **Code** — command injection, exfiltration, privilege escalation
/// 3. **Data** — PII, financial data, compliance artifacts
pub struct ExternalizationGate {
    secrets: SecretsScanner,
    code: CodeScanner,
    data: DataScanner,
}

impl ExternalizationGate {
    /// Create a new externalization gate with all scanners.
    pub fn new() -> Self {
        Self {
            secrets: SecretsScanner::new(),
            code: CodeScanner::new(),
            data: DataScanner::new(),
        }
    }

    /// Apply the externalization gate to an exec result.
    /// May redact content, block the result, or pass it through.
    pub fn apply(
        &self,
        mut result: ExecResult,
        policy: &ExternalizationPolicy,
    ) -> crate::Result<ExecResult> {
        if !policy.enabled {
            return Ok(result);
        }

        // Check size limits
        let total_size = result.stdout.len() + result.stderr.len();
        if total_size > policy.max_artifact_size_bytes {
            return Err(crate::KavachError::ExternalizationBlocked(format!(
                "output size {} exceeds limit {}",
                total_size, policy.max_artifact_size_bytes
            )));
        }

        // Scan stdout and stderr with all scanners (newline separator prevents
        // false positives at the stdout/stderr boundary)
        let combined = format!("{}\n{}", result.stdout, result.stderr);
        let mut all_findings = Vec::new();
        all_findings.extend(self.secrets.scan(&combined));
        all_findings.extend(self.code.scan(&combined));
        all_findings.extend(self.data.scan(&combined));

        let worst_severity = all_findings
            .iter()
            .map(|f| f.severity)
            .max()
            .unwrap_or(Severity::Info);

        let scan_result = ScanResult {
            verdict: determine_verdict(worst_severity, policy),
            findings: all_findings,
            worst_severity,
        };

        match scan_result.verdict {
            ScanVerdict::Block => Err(crate::KavachError::ExternalizationBlocked(format!(
                "blocked: {} finding(s), worst severity: {}",
                scan_result.findings.len(),
                scan_result.worst_severity
            ))),
            ScanVerdict::Quarantine => Err(crate::KavachError::ExternalizationBlocked(format!(
                "quarantined: {} finding(s), worst severity: {}",
                scan_result.findings.len(),
                scan_result.worst_severity
            ))),
            ScanVerdict::Warn => {
                if policy.redact_secrets {
                    result.stdout = self.secrets.redact(&result.stdout).into_owned();
                    result.stderr = self.secrets.redact(&result.stderr).into_owned();
                }
                Ok(result)
            }
            ScanVerdict::Pass => Ok(result),
        }
    }
}

impl Default for ExternalizationGate {
    fn default() -> Self {
        Self::new()
    }
}

/// Determine verdict based on worst severity and policy thresholds.
fn determine_verdict(worst: Severity, policy: &ExternalizationPolicy) -> ScanVerdict {
    if worst >= policy.block_threshold {
        ScanVerdict::Block
    } else if worst >= policy.quarantine_threshold {
        ScanVerdict::Quarantine
    } else if worst > Severity::Info {
        ScanVerdict::Warn
    } else {
        ScanVerdict::Pass
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_result(stdout: &str) -> ExecResult {
        ExecResult {
            exit_code: 0,
            stdout: stdout.into(),
            stderr: String::new(),
            duration_ms: 0,
            timed_out: false,
        }
    }

    #[test]
    fn pass_clean_output() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy::default();
        let result = gate.apply(make_result("hello world"), &policy).unwrap();
        assert_eq!(result.stdout, "hello world");
    }

    #[test]
    fn block_private_key() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy::default();
        let result = gate.apply(
            make_result("-----BEGIN RSA PRIVATE KEY-----\nMIIEp..."),
            &policy,
        );
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.to_string().contains("blocked"));
    }

    #[test]
    fn redact_medium_severity() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy {
            quarantine_threshold: Severity::High,
            block_threshold: Severity::Critical,
            ..Default::default()
        };
        let result = gate
            .apply(
                make_result(r#"config: api_key = "abcdefghijklmnopqrstuvwxyz""#),
                &policy,
            )
            .unwrap();
        assert!(result.stdout.contains("[REDACTED:"));
    }

    #[test]
    fn block_oversized() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy {
            max_artifact_size_bytes: 10,
            ..Default::default()
        };
        let result = gate.apply(make_result("this is longer than 10 bytes"), &policy);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("size"));
    }

    #[test]
    fn disabled_gate_passes_everything() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy {
            enabled: false,
            ..Default::default()
        };
        let result = gate
            .apply(make_result("-----BEGIN RSA PRIVATE KEY-----"), &policy)
            .unwrap();
        assert!(result.stdout.contains("BEGIN RSA PRIVATE KEY"));
    }

    #[test]
    fn default_gate() {
        let gate = ExternalizationGate::default();
        let policy = ExternalizationPolicy::default();
        let result = gate.apply(make_result("hello"), &policy).unwrap();
        assert_eq!(result.stdout, "hello");
    }

    #[test]
    fn quarantine_medium_severity() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy {
            quarantine_threshold: Severity::Medium,
            block_threshold: Severity::Critical,
            ..Default::default()
        };
        // Generic API key is Medium severity
        let result = gate.apply(
            make_result(r#"api_key = "abcdefghijklmnopqrstuvwxyz""#),
            &policy,
        );
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("quarantined"));
    }

    #[test]
    fn stderr_scanning() {
        let gate = ExternalizationGate::new();
        let policy = ExternalizationPolicy::default();
        let mut result = make_result("clean");
        result.stderr = "-----BEGIN RSA PRIVATE KEY-----".into();
        let outcome = gate.apply(result, &policy);
        assert!(outcome.is_err(), "secret in stderr should be caught");
    }

    #[test]
    fn verdict_determination() {
        let policy = ExternalizationPolicy::default();
        assert_eq!(
            determine_verdict(Severity::Info, &policy),
            ScanVerdict::Pass
        );
        assert_eq!(determine_verdict(Severity::Low, &policy), ScanVerdict::Warn);
        assert_eq!(
            determine_verdict(Severity::High, &policy),
            ScanVerdict::Quarantine
        );
        assert_eq!(
            determine_verdict(Severity::Critical, &policy),
            ScanVerdict::Block
        );
    }
}