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