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 fix;
26pub mod ir;
27pub mod output;
28pub mod parser;
29mod risk;
30pub mod rules;
31#[cfg(feature = "runtime-guard")]
32pub mod runtime;
33pub mod ux;
34
35use std::path::Path;
36
37use config::{Config, ScanPathFilter, ScanPathFilterSummary};
38use error::Result;
39use ir::ScanTarget;
40use output::OutputFormat;
41use rules::policy::PolicyVerdict;
42use rules::{Finding, RuleEngine};
43
44#[derive(Debug, Clone)]
46pub struct ScanOptions {
47 pub config_path: Option<std::path::PathBuf>,
49 pub format: OutputFormat,
51 pub fail_on_override: Option<rules::Severity>,
53 pub ignore_tests: bool,
55 pub custom_rules_dir: Option<std::path::PathBuf>,
57}
58
59impl Default for ScanOptions {
60 fn default() -> Self {
61 Self {
62 config_path: None,
63 format: OutputFormat::Console,
64 fail_on_override: None,
65 ignore_tests: false,
66 custom_rules_dir: None,
67 }
68 }
69}
70
71#[derive(Debug)]
73pub struct ScanReport {
74 pub target_name: String,
75 pub findings: Vec<Finding>,
76 pub verdict: PolicyVerdict,
77 pub scan_root: std::path::PathBuf,
80 pub targets: Vec<ScanTarget>,
83 pub path_filter_summary: ScanPathFilterSummary,
84}
85
86impl ScanReport {
87 pub fn render(&self, format: OutputFormat) -> Result<String> {
89 render_report(self, format)
90 }
91}
92
93pub fn scan(path: &Path, options: &ScanOptions) -> Result<ScanReport> {
95 scan_with_path_filter_overrides(path, options, &[], &[])
96}
97
98pub fn scan_with_path_filter_overrides(
105 path: &Path,
106 options: &ScanOptions,
107 include_patterns: &[String],
108 exclude_patterns: &[String],
109) -> Result<ScanReport> {
110 let config_path = options
112 .config_path
113 .clone()
114 .unwrap_or_else(|| path.join(".agentshield.toml"));
115 let mut config = Config::load(&config_path)?;
116
117 if !include_patterns.is_empty() {
120 config.scan.include.extend(include_patterns.iter().cloned());
121 }
122 if !exclude_patterns.is_empty() {
123 config.scan.exclude.extend(exclude_patterns.iter().cloned());
124 }
125
126 if let Some(fail_on) = options.fail_on_override {
128 config.policy.fail_on = fail_on;
129 }
130
131 let ignore_tests = options.ignore_tests || config.scan.ignore_tests;
133 let path_filter = ScanPathFilter::from_scan_config(&config.scan, ignore_tests)?;
134 let path_filter_summary = path_filter.summary();
135 let mut bundles = adapter::auto_detect_analysis_with_filter(path, &path_filter)?;
136
137 crate::analysis::interprocedural::analyze_and_enrich_targets(&mut bundles);
139
140 let mut engine = RuleEngine::new();
142 if let Some(ref dir) = options.custom_rules_dir {
143 let _ = engine.load_custom_rules_from(dir);
144 } else if let Some(ref dir) = config.rules.custom_dir {
145 let abs_dir = if dir.is_absolute() {
146 dir.clone()
147 } else {
148 path.join(dir)
149 };
150 let _ = engine.load_custom_rules_from(&abs_dir);
151 } else {
152 let default_rules_dir = path.join(".agentshield").join("rules");
153 if default_rules_dir.is_dir() {
154 let _ = engine.load_custom_rules_from(&default_rules_dir);
155 }
156 }
157 let mut all_findings: Vec<Finding> = Vec::new();
158
159 let target_name = if let Some(first) = bundles.first() {
160 first.target.name.clone()
161 } else {
162 path.file_name()
163 .map(|n| n.to_string_lossy().into_owned())
164 .unwrap_or_else(|| "unknown".into())
165 };
166
167 let mut targets = Vec::with_capacity(bundles.len());
168 for bundle in &bundles {
169 let input = crate::analysis::DetectionInput {
170 target: &bundle.target,
171 composite_flows: &bundle.composite_flows,
172 };
173 all_findings.extend(engine.run_with_context(&input));
174 targets.push(bundle.target.clone());
175 }
176
177 let scan_root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
179
180 let effective_findings = config.policy.apply(&all_findings, &scan_root);
182 let verdict = config.policy.evaluate(&effective_findings);
183
184 Ok(ScanReport {
185 target_name,
186 findings: effective_findings,
187 verdict,
188 scan_root,
189 targets,
190 path_filter_summary,
191 })
192}
193
194pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
196 let rule_metadata = rules::RuleEngine::new().list_scanner_rules();
197 output::render_with_metadata(
198 &report.findings,
199 &report.verdict,
200 format,
201 &report.target_name,
202 &report.scan_root,
203 &rule_metadata,
204 )
205}
206
207pub fn render_report_with_experimental_risk(
212 report: &ScanReport,
213 format: OutputFormat,
214) -> Result<String> {
215 if !matches!(format, OutputFormat::Console | OutputFormat::Json) {
216 return Err(error::ShieldError::Config(
217 "`--experimental-risk` supports only console and JSON output".to_owned(),
218 ));
219 }
220
221 let base_report = render_report(report, format)?;
222 let coverage = risk::CoverageDescriptor::current();
223 let assessment = risk::assess(&report.findings, &report.scan_root, &coverage)
224 .map_err(|error| error::ShieldError::Internal(error.to_string()))?;
225 risk::render_experimental(&base_report, &assessment, format)
226}
227
228#[cfg(test)]
229mod integration_tests {
230 use super::*;
231 use std::path::Path;
232
233 #[test]
234 fn safe_calculator_zero_findings() {
235 let opts = ScanOptions::default();
236 let report = scan(
237 Path::new("tests/fixtures/mcp_servers/safe_calculator"),
238 &opts,
239 )
240 .unwrap();
241 assert!(
243 !report
244 .findings
245 .iter()
246 .any(|f| f.severity >= rules::Severity::High),
247 "safe calculator should have no High+ findings"
248 );
249 assert!(report.verdict.pass);
250 }
251
252 #[test]
253 fn vuln_cmd_inject_detected() {
254 let opts = ScanOptions::default();
255 let report = scan(
256 Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject"),
257 &opts,
258 )
259 .unwrap();
260 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-001"));
261 assert!(!report.verdict.pass);
262 }
263
264 #[test]
265 fn vuln_ssrf_detected() {
266 let opts = ScanOptions::default();
267 let report = scan(Path::new("tests/fixtures/mcp_servers/vuln_ssrf"), &opts).unwrap();
268 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-003"));
269 assert!(!report.verdict.pass);
270 }
271
272 #[test]
273 fn vuln_cred_exfil_detected() {
274 let opts = ScanOptions::default();
275 let report = scan(
276 Path::new("tests/fixtures/mcp_servers/vuln_cred_exfil"),
277 &opts,
278 )
279 .unwrap();
280 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-002"));
281 assert!(!report.verdict.pass);
282 }
283
284 #[test]
285 #[cfg(feature = "typescript")]
286 fn vuln_read_exfil_chain_detected() {
287 let opts = ScanOptions::default();
288 let report = scan(
289 Path::new("tests/fixtures/mcp_servers/vuln_read_exfil_chain"),
290 &opts,
291 )
292 .unwrap();
293 assert!(
294 report.findings.iter().any(|f| f.rule_id == "SHIELD-020"),
295 "Expected SHIELD-020 for read-and-send file exfiltration chain"
296 );
297 assert!(
298 report.findings.iter().any(|f| f.rule_id == "SHIELD-004"),
299 "Expected SHIELD-004 to coexist with the composite finding"
300 );
301 assert!(
302 !report.findings.iter().any(|f| f.rule_id == "SHIELD-015"),
303 "SHIELD-015 should be suppressed by SHIELD-004 at the same location"
304 );
305 assert!(!report.verdict.pass);
306 }
307
308 #[test]
309 #[cfg(not(feature = "typescript"))]
310 fn vuln_read_exfil_chain_is_not_emitted_without_typescript() {
311 let opts = ScanOptions::default();
312 let report = scan(
313 Path::new("tests/fixtures/mcp_servers/vuln_read_exfil_chain"),
314 &opts,
315 )
316 .unwrap();
317 assert!(
318 !report.findings.iter().any(|f| f.rule_id == "SHIELD-020"),
319 "SHIELD-020 requires the TypeScript composite-flow analyzer"
320 );
321 }
322
323 #[test]
324 fn baseline_write_and_filter_round_trip() {
325 use crate::baseline::{BaselineEntry, BaselineFile};
326 use tempfile::NamedTempFile;
327
328 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
329 let opts = ScanOptions::default();
330
331 let report = scan(fixture, &opts).unwrap();
333 assert!(
334 !report.findings.is_empty(),
335 "vuln_cmd_inject should produce findings"
336 );
337
338 let baseline_file = NamedTempFile::new().unwrap();
340 let now = chrono::Utc::now().to_rfc3339();
341 let entries: Vec<BaselineEntry> = report
342 .findings
343 .iter()
344 .map(|f| BaselineEntry {
345 fingerprint: f.fingerprint(&report.scan_root),
346 rule_id: f.rule_id.clone(),
347 first_seen: now.clone(),
348 })
349 .collect();
350 let baseline = BaselineFile::new(entries);
351 baseline.save(baseline_file.path()).unwrap();
352
353 let report2 = scan(fixture, &opts).unwrap();
355 let loaded_baseline = BaselineFile::load(baseline_file.path()).unwrap();
356 let filtered: Vec<_> = report2
357 .findings
358 .into_iter()
359 .filter(|f| {
360 let fp = f.fingerprint(&report2.scan_root);
361 !loaded_baseline.contains(&fp)
362 })
363 .collect();
364
365 assert!(
367 filtered.is_empty(),
368 "All findings should be filtered by baseline, but {} remain: {:?}",
369 filtered.len(),
370 filtered.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
371 );
372 }
373
374 #[test]
375 fn suppress_command_roundtrip() {
376 use crate::config::Config;
377 use crate::rules::policy::Suppression;
378 use tempfile::TempDir;
379
380 let tmp = TempDir::new().unwrap();
382 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
383
384 let opts = ScanOptions::default();
386 let report = scan(fixture, &opts).unwrap();
387 assert!(
388 !report.findings.is_empty(),
389 "vuln_cmd_inject should produce findings"
390 );
391
392 let first_finding = &report.findings[0];
393 let fp = first_finding.fingerprint(&report.scan_root);
394 let rule_id = first_finding.rule_id.clone();
395
396 let config_path = tmp.path().join(".agentshield.toml");
398 let mut cfg = Config::default();
399 cfg.policy.suppressions.push(Suppression {
400 fingerprint: fp.clone(),
401 reason: "Integration test suppression".into(),
402 expires: None,
403 created_at: Some("2026-03-21".into()),
404 });
405 let toml_str = toml::to_string_pretty(&cfg).unwrap();
406 std::fs::write(&config_path, &toml_str).unwrap();
407
408 let loaded = Config::load(&config_path).unwrap();
410 assert_eq!(loaded.policy.suppressions.len(), 1);
411 assert_eq!(loaded.policy.suppressions[0].fingerprint, fp);
412 assert_eq!(
413 loaded.policy.suppressions[0].reason,
414 "Integration test suppression"
415 );
416
417 let opts_with_config = ScanOptions {
419 config_path: Some(config_path.clone()),
420 ..ScanOptions::default()
421 };
422 let report2 = scan(fixture, &opts_with_config).unwrap();
423
424 let still_present = report2
426 .findings
427 .iter()
428 .any(|f| f.rule_id == rule_id && f.fingerprint(&report2.scan_root) == fp);
429
430 assert!(
431 !still_present,
432 "Suppressed finding {} should not appear in re-scan",
433 fp
434 );
435 }
436
437 #[test]
445 fn dep_findings_location_parity_across_output_formats() {
446 use crate::output::OutputFormat;
447
448 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_unpinned_deps");
449 let opts = ScanOptions::default();
450 let report = scan(fixture, &opts).unwrap();
451
452 let dep_finding = report
455 .findings
456 .iter()
457 .find(|f| f.rule_id == "SHIELD-009")
458 .expect("Expected at least one SHIELD-009 finding from vuln_unpinned_deps fixture");
459
460 let loc = dep_finding
462 .location
463 .as_ref()
464 .expect("SHIELD-009 finding must carry a manifest file location");
465 assert!(
466 loc.file.to_string_lossy().contains("requirements.txt"),
467 "SHIELD-009 location file should be requirements.txt, got: {}",
468 loc.file.display()
469 );
470 assert!(loc.line >= 1, "SHIELD-009 location line must be >= 1");
471
472 let expected_file = loc.file.to_string_lossy().to_string();
473
474 let console_out =
476 render_report(&report, OutputFormat::Console).expect("console render failed");
477 assert!(
478 console_out.contains("requirements.txt"),
479 "Console output should contain requirements.txt for dep findings"
480 );
481
482 let json_out = render_report(&report, OutputFormat::Json).expect("json render failed");
483 let json_val: serde_json::Value =
484 serde_json::from_str(&json_out).expect("JSON output must be valid JSON");
485 let json_findings = json_val["findings"]
486 .as_array()
487 .expect("JSON must have findings array");
488 let json_dep = json_findings
489 .iter()
490 .find(|f| f["rule_id"].as_str() == Some("SHIELD-009"))
491 .expect("JSON output must contain SHIELD-009 finding");
492 let json_file = json_dep["location"]["file"]
493 .as_str()
494 .expect("JSON SHIELD-009 finding must have location.file");
495 assert!(
496 json_file.contains("requirements.txt"),
497 "JSON location.file should contain requirements.txt, got: {json_file}"
498 );
499
500 let sarif_out = render_report(&report, OutputFormat::Sarif).expect("SARIF render failed");
501 let sarif_val: serde_json::Value =
502 serde_json::from_str(&sarif_out).expect("SARIF output must be valid JSON");
503 let sarif_results = sarif_val["runs"][0]["results"]
504 .as_array()
505 .expect("SARIF must have runs[0].results array");
506 let sarif_dep = sarif_results
507 .iter()
508 .find(|r| r["ruleId"].as_str() == Some("SHIELD-009"))
509 .expect(
510 "SARIF output must contain SHIELD-009 result (dep findings now have locations)",
511 );
512 let sarif_uri = sarif_dep["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
513 .as_str()
514 .expect("SARIF SHIELD-009 result must have a physicalLocation URI");
515 assert!(
516 sarif_uri.contains("requirements.txt"),
517 "SARIF artifactLocation URI should contain requirements.txt, got: {sarif_uri}"
518 );
519 assert!(
521 expected_file.contains("requirements.txt"),
522 "Location file {expected_file} must reference requirements.txt"
523 );
524
525 let html_out = render_report(&report, OutputFormat::Html).expect("HTML render failed");
526 assert!(
527 html_out.contains("requirements.txt"),
528 "HTML output should contain requirements.txt for dep findings"
529 );
530 assert!(
533 !html_out.contains("<code>-</code>"),
534 "HTML output must not show '-' for dep finding locations that have a manifest file"
535 );
536 }
537
538 #[test]
539 fn vuln_metadata_ssrf_detected() {
540 let opts = ScanOptions::default();
541 let report = scan(
542 Path::new("tests/fixtures/mcp_servers/vuln_metadata_ssrf"),
543 &opts,
544 )
545 .unwrap();
546 assert!(
550 report.findings.iter().any(|f| f.rule_id == "SHIELD-013"),
551 "Expected SHIELD-013 (metadata SSRF) from vuln_metadata_ssrf fixture"
552 );
553 assert!(
554 report.findings.iter().any(|f| f.rule_id == "SHIELD-003"),
555 "SHIELD-003 remains useful when the taint path has no concrete metadata URL"
556 );
557 assert!(!report.verdict.pass);
558 }
559
560 #[cfg(feature = "typescript")]
565 #[test]
566 fn safe_filesystem_no_file_access_findings() {
567 let opts = ScanOptions::default();
571 let report = scan(
572 Path::new("tests/fixtures/mcp_servers/safe_filesystem"),
573 &opts,
574 )
575 .unwrap();
576
577 let file_access_findings: Vec<_> = report
578 .findings
579 .iter()
580 .filter(|f| f.rule_id == "SHIELD-004")
581 .collect();
582
583 assert!(
584 file_access_findings.is_empty(),
585 "Expected 0 SHIELD-004 findings (cross-file sanitization should eliminate FPs), \
586 but got {}: {:?}",
587 file_access_findings.len(),
588 file_access_findings
589 .iter()
590 .map(|f| &f.message)
591 .collect::<Vec<_>>()
592 );
593 }
594}