1pub mod adapter;
18pub mod analysis;
19pub mod config;
20pub mod error;
21pub mod ir;
22pub mod output;
23pub mod parser;
24pub mod rules;
25
26use std::path::Path;
27
28use config::Config;
29use error::Result;
30use output::OutputFormat;
31use rules::policy::PolicyVerdict;
32use rules::{Finding, RuleEngine};
33
34#[derive(Debug, Clone)]
36pub struct ScanOptions {
37 pub config_path: Option<std::path::PathBuf>,
39 pub format: OutputFormat,
41 pub fail_on_override: Option<rules::Severity>,
43}
44
45impl Default for ScanOptions {
46 fn default() -> Self {
47 Self {
48 config_path: None,
49 format: OutputFormat::Console,
50 fail_on_override: None,
51 }
52 }
53}
54
55#[derive(Debug)]
57pub struct ScanReport {
58 pub target_name: String,
59 pub findings: Vec<Finding>,
60 pub verdict: PolicyVerdict,
61}
62
63pub fn scan(path: &Path, options: &ScanOptions) -> Result<ScanReport> {
65 let config_path = options
67 .config_path
68 .clone()
69 .unwrap_or_else(|| path.join(".agentshield.toml"));
70 let mut config = Config::load(&config_path)?;
71
72 if let Some(fail_on) = options.fail_on_override {
74 config.policy.fail_on = fail_on;
75 }
76
77 let targets = adapter::auto_detect_and_load(path)?;
79
80 let engine = RuleEngine::new();
82 let mut all_findings: Vec<Finding> = Vec::new();
83
84 let target_name = if let Some(first) = targets.first() {
85 first.name.clone()
86 } else {
87 path.file_name()
88 .map(|n| n.to_string_lossy().into_owned())
89 .unwrap_or_else(|| "unknown".into())
90 };
91
92 for target in &targets {
93 let findings = engine.run(target);
94 all_findings.extend(findings);
95 }
96
97 let effective_findings = config.policy.apply(&all_findings);
99 let verdict = config.policy.evaluate(&all_findings);
100
101 Ok(ScanReport {
102 target_name,
103 findings: effective_findings,
104 verdict,
105 })
106}
107
108pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
110 output::render(
111 &report.findings,
112 &report.verdict,
113 format,
114 &report.target_name,
115 )
116}
117
118#[cfg(test)]
119mod integration_tests {
120 use super::*;
121 use std::path::Path;
122
123 #[test]
124 fn safe_calculator_zero_findings() {
125 let opts = ScanOptions::default();
126 let report = scan(
127 Path::new("tests/fixtures/mcp_servers/safe_calculator"),
128 &opts,
129 )
130 .unwrap();
131 assert!(
133 !report
134 .findings
135 .iter()
136 .any(|f| f.severity >= rules::Severity::High),
137 "safe calculator should have no High+ findings"
138 );
139 assert!(report.verdict.pass);
140 }
141
142 #[test]
143 fn vuln_cmd_inject_detected() {
144 let opts = ScanOptions::default();
145 let report = scan(
146 Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject"),
147 &opts,
148 )
149 .unwrap();
150 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-001"));
151 assert!(!report.verdict.pass);
152 }
153
154 #[test]
155 fn vuln_ssrf_detected() {
156 let opts = ScanOptions::default();
157 let report = scan(Path::new("tests/fixtures/mcp_servers/vuln_ssrf"), &opts).unwrap();
158 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-003"));
159 assert!(!report.verdict.pass);
160 }
161
162 #[test]
163 fn vuln_cred_exfil_detected() {
164 let opts = ScanOptions::default();
165 let report = scan(
166 Path::new("tests/fixtures/mcp_servers/vuln_cred_exfil"),
167 &opts,
168 )
169 .unwrap();
170 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-002"));
171 assert!(!report.verdict.pass);
172 }
173}