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