1pub mod adapter;
18pub mod analysis;
19pub mod baseline;
20pub mod certify;
21pub mod config;
22pub mod egress;
23pub mod error;
24pub mod ir;
25pub mod output;
26pub mod parser;
27pub mod rules;
28
29use std::path::Path;
30
31use config::Config;
32use error::Result;
33use ir::ScanTarget;
34use output::OutputFormat;
35use rules::policy::PolicyVerdict;
36use rules::{Finding, RuleEngine};
37
38#[derive(Debug, Clone)]
40pub struct ScanOptions {
41 pub config_path: Option<std::path::PathBuf>,
43 pub format: OutputFormat,
45 pub fail_on_override: Option<rules::Severity>,
47 pub ignore_tests: bool,
49}
50
51impl Default for ScanOptions {
52 fn default() -> Self {
53 Self {
54 config_path: None,
55 format: OutputFormat::Console,
56 fail_on_override: None,
57 ignore_tests: false,
58 }
59 }
60}
61
62#[derive(Debug)]
64pub struct ScanReport {
65 pub target_name: String,
66 pub findings: Vec<Finding>,
67 pub verdict: PolicyVerdict,
68 pub scan_root: std::path::PathBuf,
71 pub targets: Vec<ScanTarget>,
74}
75
76pub fn scan(path: &Path, options: &ScanOptions) -> Result<ScanReport> {
78 let config_path = options
80 .config_path
81 .clone()
82 .unwrap_or_else(|| path.join(".agentshield.toml"));
83 let mut config = Config::load(&config_path)?;
84
85 if let Some(fail_on) = options.fail_on_override {
87 config.policy.fail_on = fail_on;
88 }
89
90 let ignore_tests = options.ignore_tests || config.scan.ignore_tests;
92 let targets = adapter::auto_detect_and_load(path, ignore_tests)?;
93
94 let engine = RuleEngine::new();
96 let mut all_findings: Vec<Finding> = Vec::new();
97
98 let target_name = if let Some(first) = targets.first() {
99 first.name.clone()
100 } else {
101 path.file_name()
102 .map(|n| n.to_string_lossy().into_owned())
103 .unwrap_or_else(|| "unknown".into())
104 };
105
106 for target in &targets {
107 let findings = engine.run(target);
108 all_findings.extend(findings);
109 }
110
111 let scan_root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
113
114 let effective_findings = config.policy.apply(&all_findings, &scan_root);
116 let verdict = config.policy.evaluate(&all_findings);
117
118 Ok(ScanReport {
119 target_name,
120 findings: effective_findings,
121 verdict,
122 scan_root,
123 targets,
124 })
125}
126
127pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
129 output::render(
130 &report.findings,
131 &report.verdict,
132 format,
133 &report.target_name,
134 &report.scan_root,
135 )
136}
137
138#[cfg(test)]
139mod integration_tests {
140 use super::*;
141 use std::path::Path;
142
143 #[test]
144 fn safe_calculator_zero_findings() {
145 let opts = ScanOptions::default();
146 let report = scan(
147 Path::new("tests/fixtures/mcp_servers/safe_calculator"),
148 &opts,
149 )
150 .unwrap();
151 assert!(
153 !report
154 .findings
155 .iter()
156 .any(|f| f.severity >= rules::Severity::High),
157 "safe calculator should have no High+ findings"
158 );
159 assert!(report.verdict.pass);
160 }
161
162 #[test]
163 fn vuln_cmd_inject_detected() {
164 let opts = ScanOptions::default();
165 let report = scan(
166 Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject"),
167 &opts,
168 )
169 .unwrap();
170 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-001"));
171 assert!(!report.verdict.pass);
172 }
173
174 #[test]
175 fn vuln_ssrf_detected() {
176 let opts = ScanOptions::default();
177 let report = scan(Path::new("tests/fixtures/mcp_servers/vuln_ssrf"), &opts).unwrap();
178 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-003"));
179 assert!(!report.verdict.pass);
180 }
181
182 #[test]
183 fn vuln_cred_exfil_detected() {
184 let opts = ScanOptions::default();
185 let report = scan(
186 Path::new("tests/fixtures/mcp_servers/vuln_cred_exfil"),
187 &opts,
188 )
189 .unwrap();
190 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-002"));
191 assert!(!report.verdict.pass);
192 }
193
194 #[test]
195 fn baseline_write_and_filter_round_trip() {
196 use crate::baseline::{BaselineEntry, BaselineFile};
197 use tempfile::NamedTempFile;
198
199 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
200 let opts = ScanOptions::default();
201
202 let report = scan(fixture, &opts).unwrap();
204 assert!(
205 !report.findings.is_empty(),
206 "vuln_cmd_inject should produce findings"
207 );
208
209 let baseline_file = NamedTempFile::new().unwrap();
211 let now = chrono::Utc::now().to_rfc3339();
212 let entries: Vec<BaselineEntry> = report
213 .findings
214 .iter()
215 .map(|f| BaselineEntry {
216 fingerprint: f.fingerprint(&report.scan_root),
217 rule_id: f.rule_id.clone(),
218 first_seen: now.clone(),
219 })
220 .collect();
221 let baseline = BaselineFile::new(entries);
222 baseline.save(baseline_file.path()).unwrap();
223
224 let report2 = scan(fixture, &opts).unwrap();
226 let loaded_baseline = BaselineFile::load(baseline_file.path()).unwrap();
227 let filtered: Vec<_> = report2
228 .findings
229 .into_iter()
230 .filter(|f| {
231 let fp = f.fingerprint(&report2.scan_root);
232 !loaded_baseline.contains(&fp)
233 })
234 .collect();
235
236 assert!(
238 filtered.is_empty(),
239 "All findings should be filtered by baseline, but {} remain: {:?}",
240 filtered.len(),
241 filtered.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
242 );
243 }
244
245 #[test]
246 fn suppress_command_roundtrip() {
247 use crate::config::Config;
248 use crate::rules::policy::Suppression;
249 use tempfile::TempDir;
250
251 let tmp = TempDir::new().unwrap();
253 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
254
255 let opts = ScanOptions::default();
257 let report = scan(fixture, &opts).unwrap();
258 assert!(
259 !report.findings.is_empty(),
260 "vuln_cmd_inject should produce findings"
261 );
262
263 let first_finding = &report.findings[0];
264 let fp = first_finding.fingerprint(&report.scan_root);
265 let rule_id = first_finding.rule_id.clone();
266
267 let config_path = tmp.path().join(".agentshield.toml");
269 let mut cfg = Config::default();
270 cfg.policy.suppressions.push(Suppression {
271 fingerprint: fp.clone(),
272 reason: "Integration test suppression".into(),
273 expires: None,
274 created_at: Some("2026-03-21".into()),
275 });
276 let toml_str = toml::to_string_pretty(&cfg).unwrap();
277 std::fs::write(&config_path, &toml_str).unwrap();
278
279 let loaded = Config::load(&config_path).unwrap();
281 assert_eq!(loaded.policy.suppressions.len(), 1);
282 assert_eq!(loaded.policy.suppressions[0].fingerprint, fp);
283 assert_eq!(
284 loaded.policy.suppressions[0].reason,
285 "Integration test suppression"
286 );
287
288 let opts_with_config = ScanOptions {
290 config_path: Some(config_path.clone()),
291 ..ScanOptions::default()
292 };
293 let report2 = scan(fixture, &opts_with_config).unwrap();
294
295 let still_present = report2
297 .findings
298 .iter()
299 .any(|f| f.rule_id == rule_id && f.fingerprint(&report2.scan_root) == fp);
300
301 assert!(
302 !still_present,
303 "Suppressed finding {} should not appear in re-scan",
304 fp
305 );
306 }
307
308 #[test]
316 fn dep_findings_location_parity_across_output_formats() {
317 use crate::output::OutputFormat;
318
319 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_unpinned_deps");
320 let opts = ScanOptions::default();
321 let report = scan(fixture, &opts).unwrap();
322
323 let dep_finding = report
326 .findings
327 .iter()
328 .find(|f| f.rule_id == "SHIELD-009")
329 .expect("Expected at least one SHIELD-009 finding from vuln_unpinned_deps fixture");
330
331 let loc = dep_finding
333 .location
334 .as_ref()
335 .expect("SHIELD-009 finding must carry a manifest file location");
336 assert!(
337 loc.file.to_string_lossy().contains("requirements.txt"),
338 "SHIELD-009 location file should be requirements.txt, got: {}",
339 loc.file.display()
340 );
341 assert!(loc.line >= 1, "SHIELD-009 location line must be >= 1");
342
343 let expected_file = loc.file.to_string_lossy().to_string();
344
345 let console_out =
347 render_report(&report, OutputFormat::Console).expect("console render failed");
348 assert!(
349 console_out.contains("requirements.txt"),
350 "Console output should contain requirements.txt for dep findings"
351 );
352
353 let json_out = render_report(&report, OutputFormat::Json).expect("json render failed");
354 let json_val: serde_json::Value =
355 serde_json::from_str(&json_out).expect("JSON output must be valid JSON");
356 let json_findings = json_val["findings"]
357 .as_array()
358 .expect("JSON must have findings array");
359 let json_dep = json_findings
360 .iter()
361 .find(|f| f["rule_id"].as_str() == Some("SHIELD-009"))
362 .expect("JSON output must contain SHIELD-009 finding");
363 let json_file = json_dep["location"]["file"]
364 .as_str()
365 .expect("JSON SHIELD-009 finding must have location.file");
366 assert!(
367 json_file.contains("requirements.txt"),
368 "JSON location.file should contain requirements.txt, got: {json_file}"
369 );
370
371 let sarif_out = render_report(&report, OutputFormat::Sarif).expect("SARIF render failed");
372 let sarif_val: serde_json::Value =
373 serde_json::from_str(&sarif_out).expect("SARIF output must be valid JSON");
374 let sarif_results = sarif_val["runs"][0]["results"]
375 .as_array()
376 .expect("SARIF must have runs[0].results array");
377 let sarif_dep = sarif_results
378 .iter()
379 .find(|r| r["ruleId"].as_str() == Some("SHIELD-009"))
380 .expect(
381 "SARIF output must contain SHIELD-009 result (dep findings now have locations)",
382 );
383 let sarif_uri = sarif_dep["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
384 .as_str()
385 .expect("SARIF SHIELD-009 result must have a physicalLocation URI");
386 assert!(
387 sarif_uri.contains("requirements.txt"),
388 "SARIF artifactLocation URI should contain requirements.txt, got: {sarif_uri}"
389 );
390 assert!(
392 expected_file.contains("requirements.txt"),
393 "Location file {expected_file} must reference requirements.txt"
394 );
395
396 let html_out = render_report(&report, OutputFormat::Html).expect("HTML render failed");
397 assert!(
398 html_out.contains("requirements.txt"),
399 "HTML output should contain requirements.txt for dep findings"
400 );
401 assert!(
404 !html_out.contains("<code>-</code>"),
405 "HTML output must not show '-' for dep finding locations that have a manifest file"
406 );
407 }
408
409 #[test]
410 fn vuln_metadata_ssrf_detected() {
411 let opts = ScanOptions::default();
412 let report = scan(
413 Path::new("tests/fixtures/mcp_servers/vuln_metadata_ssrf"),
414 &opts,
415 )
416 .unwrap();
417 assert!(
422 report.findings.iter().any(|f| f.rule_id == "SHIELD-003"),
423 "Expected SHIELD-003 (general SSRF) from vuln_metadata_ssrf fixture"
424 );
425 assert!(!report.verdict.pass);
426 }
427
428 #[test]
429 fn safe_filesystem_no_file_access_findings() {
430 let opts = ScanOptions::default();
434 let report = scan(
435 Path::new("tests/fixtures/mcp_servers/safe_filesystem"),
436 &opts,
437 )
438 .unwrap();
439
440 let file_access_findings: Vec<_> = report
441 .findings
442 .iter()
443 .filter(|f| f.rule_id == "SHIELD-004")
444 .collect();
445
446 assert!(
447 file_access_findings.is_empty(),
448 "Expected 0 SHIELD-004 findings (cross-file sanitization should eliminate FPs), \
449 but got {}: {:?}",
450 file_access_findings.len(),
451 file_access_findings
452 .iter()
453 .map(|f| &f.message)
454 .collect::<Vec<_>>()
455 );
456 }
457}