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