Skip to main content

mdx_rust_core/
security.rs

1//! Lightweight agent security audit checks.
2//!
3//! This module intentionally starts with deterministic static checks. The goal
4//! is to surface risky agent surfaces early without executing untrusted code.
5
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
10pub enum AuditSeverity {
11    Info,
12    Low,
13    Medium,
14    High,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AuditFinding {
19    pub id: String,
20    pub severity: AuditSeverity,
21    pub title: String,
22    pub description: String,
23    #[serde(default)]
24    pub file: Option<String>,
25    #[serde(default)]
26    pub line: Option<usize>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SecurityAuditReport {
31    pub root: String,
32    pub findings: Vec<AuditFinding>,
33}
34
35impl SecurityAuditReport {
36    pub fn summary(&self) -> String {
37        let high = self
38            .findings
39            .iter()
40            .filter(|finding| finding.severity == AuditSeverity::High)
41            .count();
42        let medium = self
43            .findings
44            .iter()
45            .filter(|finding| finding.severity == AuditSeverity::Medium)
46            .count();
47        format!(
48            "{} finding(s), {} high, {} medium",
49            self.findings.len(),
50            high,
51            medium
52        )
53    }
54}
55
56pub fn audit_agent(root: &Path) -> anyhow::Result<SecurityAuditReport> {
57    let mut findings = Vec::new();
58    let files = collect_rust_files(root)?;
59
60    for file in files {
61        let content = std::fs::read_to_string(&file)?;
62        for (index, line) in content.lines().enumerate() {
63            let line_no = index + 1;
64            let trimmed = line.trim();
65
66            if trimmed.contains("Command::new(") || trimmed.contains("std::process::Command") {
67                findings.push(finding(
68                    "unexpected-code-execution",
69                    AuditSeverity::High,
70                    "Process execution surface",
71                    "Agent code starts external processes. Review command inputs, allowlists, and sandbox boundaries.",
72                    &file,
73                    line_no,
74                ));
75            }
76
77            if trimmed.contains("unsafe ") || trimmed == "unsafe" || trimmed.contains("unsafe{") {
78                findings.push(finding(
79                    "unsafe-code",
80                    AuditSeverity::Medium,
81                    "Unsafe Rust block",
82                    "Unsafe code increases the blast radius of agent-driven changes and should have a clear justification.",
83                    &file,
84                    line_no,
85                ));
86            }
87
88            if contains_secret_literal(trimmed) {
89                findings.push(finding(
90                    "secret-literal",
91                    AuditSeverity::High,
92                    "Potential secret literal",
93                    "A likely secret or token appears in source. Move secrets to environment or a managed secret store.",
94                    &file,
95                    line_no,
96                ));
97            }
98
99            if trimmed.contains("MCP") || trimmed.contains("mcp") || trimmed.contains("A2A") {
100                findings.push(finding(
101                    "agent-interop-surface",
102                    AuditSeverity::Low,
103                    "Agent interop surface",
104                    "MCP or A2A-style integration should validate tool schemas and trust boundaries before live execution.",
105                    &file,
106                    line_no,
107                ));
108            }
109        }
110    }
111
112    if findings.is_empty() {
113        findings.push(AuditFinding {
114            id: "baseline".to_string(),
115            severity: AuditSeverity::Info,
116            title: "No obvious static risks found".to_string(),
117            description: "Static audit found no process execution, unsafe code, obvious secret literals, or agent interop surfaces.".to_string(),
118            file: None,
119            line: None,
120        });
121    }
122
123    Ok(SecurityAuditReport {
124        root: root.display().to_string(),
125        findings,
126    })
127}
128
129fn collect_rust_files(root: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
130    let mut files = Vec::new();
131    collect_rust_files_inner(root, &mut files)?;
132    Ok(files)
133}
134
135fn collect_rust_files_inner(
136    root: &Path,
137    files: &mut Vec<std::path::PathBuf>,
138) -> anyhow::Result<()> {
139    if !root.exists() {
140        return Ok(());
141    }
142
143    for entry in std::fs::read_dir(root)? {
144        let entry = entry?;
145        let path = entry.path();
146        let name = entry.file_name();
147        let name = name.to_string_lossy();
148
149        if path.is_dir() {
150            if matches!(
151                name.as_ref(),
152                "target" | ".git" | ".worktrees" | ".mdx-rust"
153            ) {
154                continue;
155            }
156            collect_rust_files_inner(&path, files)?;
157        } else if path.extension().is_some_and(|extension| extension == "rs") {
158            files.push(path);
159        }
160    }
161
162    Ok(())
163}
164
165fn finding(
166    id: &str,
167    severity: AuditSeverity,
168    title: &str,
169    description: &str,
170    file: &Path,
171    line: usize,
172) -> AuditFinding {
173    AuditFinding {
174        id: id.to_string(),
175        severity,
176        title: title.to_string(),
177        description: description.to_string(),
178        file: Some(file.display().to_string()),
179        line: Some(line),
180    }
181}
182
183fn contains_secret_literal(line: &str) -> bool {
184    if line.contains("env::var(")
185        || line.contains("std::env::var(")
186        || line.contains("option_env!(")
187    {
188        return false;
189    }
190
191    let lower = line.to_lowercase();
192    let has_secret_name = ["api_key", "apikey", "secret", "token", "password"]
193        .iter()
194        .any(|needle| lower.contains(needle));
195    has_secret_name && line.contains('"') && line.contains('=')
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use tempfile::tempdir;
202
203    #[test]
204    fn audit_flags_process_execution_and_secret_literals() {
205        let dir = tempdir().unwrap();
206        let src = dir.path().join("src");
207        std::fs::create_dir_all(&src).unwrap();
208        std::fs::write(
209            src.join("main.rs"),
210            r#"
211            fn main() {
212                let api_key = "secret";
213                let _ = std::process::Command::new("sh");
214            }
215            "#,
216        )
217        .unwrap();
218
219        let report = audit_agent(dir.path()).unwrap();
220
221        assert!(report
222            .findings
223            .iter()
224            .any(|finding| finding.id == "unexpected-code-execution"));
225        assert!(report
226            .findings
227            .iter()
228            .any(|finding| finding.id == "secret-literal"));
229    }
230
231    #[test]
232    fn audit_does_not_flag_environment_variable_names_as_secret_literals() {
233        let dir = tempdir().unwrap();
234        let src = dir.path().join("src");
235        std::fs::create_dir_all(&src).unwrap();
236        std::fs::write(
237            src.join("main.rs"),
238            r#"
239            fn main() {
240                let api_key = std::env::var("OPENAI_API_KEY").ok();
241            }
242            "#,
243        )
244        .unwrap();
245
246        let report = audit_agent(dir.path()).unwrap();
247
248        assert!(!report
249            .findings
250            .iter()
251            .any(|finding| finding.id == "secret-literal"));
252    }
253}