1use 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}