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 if let Err(err) = engine.load_custom_rules_from(dir) {
144 tracing::warn!(dir = %dir.display(), error = %err, "Failed to load custom rules");
145 }
146 } else if let Some(ref dir) = config.rules.custom_dir {
147 let abs_dir = if dir.is_absolute() {
148 dir.clone()
149 } else {
150 path.join(dir)
151 };
152 if let Err(err) = engine.load_custom_rules_from(&abs_dir) {
153 tracing::warn!(dir = %abs_dir.display(), error = %err, "Failed to load custom rules");
154 }
155 } else {
156 let default_rules_dir = path.join(".agentshield").join("rules");
157 if default_rules_dir.is_dir() {
158 if let Err(err) = engine.load_custom_rules_from(&default_rules_dir) {
159 tracing::warn!(
160 dir = %default_rules_dir.display(),
161 error = %err,
162 "Failed to load default custom rules"
163 );
164 }
165 }
166 }
167 let mut all_findings: Vec<Finding> = Vec::new();
168
169 let target_name = if let Some(first) = bundles.first() {
170 first.target.name.clone()
171 } else {
172 path.file_name()
173 .map(|n| n.to_string_lossy().into_owned())
174 .unwrap_or_else(|| "unknown".into())
175 };
176
177 let mut targets = Vec::with_capacity(bundles.len());
178 for bundle in &bundles {
179 let input = crate::analysis::DetectionInput {
180 target: &bundle.target,
181 composite_flows: &bundle.composite_flows,
182 };
183 all_findings.extend(engine.run_with_context(&input));
184 targets.push(bundle.target.clone());
185 }
186
187 let scan_root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
189
190 let effective_findings = config.policy.apply(&all_findings, &scan_root);
192 let verdict = config.policy.evaluate(&effective_findings);
193
194 Ok(ScanReport {
195 target_name,
196 findings: effective_findings,
197 verdict,
198 scan_root,
199 targets,
200 path_filter_summary,
201 })
202}
203
204pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
206 let rule_metadata = rules::RuleEngine::new().list_scanner_rules();
207 output::render_with_metadata(
208 &report.findings,
209 &report.verdict,
210 format,
211 &report.target_name,
212 &report.scan_root,
213 &rule_metadata,
214 )
215}
216
217pub fn render_report_with_experimental_risk(
222 report: &ScanReport,
223 format: OutputFormat,
224) -> Result<String> {
225 if !matches!(format, OutputFormat::Console | OutputFormat::Json) {
226 return Err(error::ShieldError::Config(
227 "`--experimental-risk` supports only console and JSON output".to_owned(),
228 ));
229 }
230
231 let base_report = render_report(report, format)?;
232 let coverage = risk::CoverageDescriptor::current();
233 let assessment = risk::assess(&report.findings, &report.scan_root, &coverage)
234 .map_err(|error| error::ShieldError::Internal(error.to_string()))?;
235 risk::render_experimental(&base_report, &assessment, format)
236}
237
238#[cfg(test)]
239mod integration_tests {
240 use super::*;
241 use std::path::Path;
242
243 #[test]
244 fn safe_calculator_zero_findings() {
245 let opts = ScanOptions::default();
246 let report = scan(
247 Path::new("tests/fixtures/mcp_servers/safe_calculator"),
248 &opts,
249 )
250 .unwrap();
251 assert!(
253 !report
254 .findings
255 .iter()
256 .any(|f| f.severity >= rules::Severity::High),
257 "safe calculator should have no High+ findings"
258 );
259 assert!(report.verdict.pass);
260 }
261
262 #[test]
263 fn vuln_cmd_inject_detected() {
264 let opts = ScanOptions::default();
265 let report = scan(
266 Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject"),
267 &opts,
268 )
269 .unwrap();
270 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-001"));
271 assert!(!report.verdict.pass);
272 }
273
274 #[test]
275 fn vuln_ssrf_detected() {
276 let opts = ScanOptions::default();
277 let report = scan(Path::new("tests/fixtures/mcp_servers/vuln_ssrf"), &opts).unwrap();
278 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-003"));
279 assert!(!report.verdict.pass);
280 }
281
282 #[test]
283 fn vuln_cred_exfil_detected() {
284 let opts = ScanOptions::default();
285 let report = scan(
286 Path::new("tests/fixtures/mcp_servers/vuln_cred_exfil"),
287 &opts,
288 )
289 .unwrap();
290 assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-002"));
291 assert!(!report.verdict.pass);
292 }
293
294 #[test]
295 #[cfg(feature = "typescript")]
296 fn vuln_read_exfil_chain_detected() {
297 let opts = ScanOptions::default();
298 let report = scan(
299 Path::new("tests/fixtures/mcp_servers/vuln_read_exfil_chain"),
300 &opts,
301 )
302 .unwrap();
303 assert!(
304 report.findings.iter().any(|f| f.rule_id == "SHIELD-020"),
305 "Expected SHIELD-020 for read-and-send file exfiltration chain"
306 );
307 assert!(
308 report.findings.iter().any(|f| f.rule_id == "SHIELD-004"),
309 "Expected SHIELD-004 to coexist with the composite finding"
310 );
311 assert!(
312 !report.findings.iter().any(|f| f.rule_id == "SHIELD-015"),
313 "SHIELD-015 should be suppressed by SHIELD-004 at the same location"
314 );
315 assert!(!report.verdict.pass);
316 }
317
318 #[test]
319 #[cfg(not(feature = "typescript"))]
320 fn vuln_read_exfil_chain_is_not_emitted_without_typescript() {
321 let opts = ScanOptions::default();
322 let report = scan(
323 Path::new("tests/fixtures/mcp_servers/vuln_read_exfil_chain"),
324 &opts,
325 )
326 .unwrap();
327 assert!(
328 !report.findings.iter().any(|f| f.rule_id == "SHIELD-020"),
329 "SHIELD-020 requires the TypeScript composite-flow analyzer"
330 );
331 }
332
333 #[test]
334 fn baseline_write_and_filter_round_trip() {
335 use crate::baseline::{BaselineEntry, BaselineFile};
336 use tempfile::NamedTempFile;
337
338 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
339 let opts = ScanOptions::default();
340
341 let report = scan(fixture, &opts).unwrap();
343 assert!(
344 !report.findings.is_empty(),
345 "vuln_cmd_inject should produce findings"
346 );
347
348 let baseline_file = NamedTempFile::new().unwrap();
350 let now = chrono::Utc::now().to_rfc3339();
351 let entries: Vec<BaselineEntry> = report
352 .findings
353 .iter()
354 .map(|f| BaselineEntry {
355 fingerprint: f.fingerprint(&report.scan_root),
356 rule_id: f.rule_id.clone(),
357 first_seen: now.clone(),
358 })
359 .collect();
360 let baseline = BaselineFile::new(entries);
361 baseline.save(baseline_file.path()).unwrap();
362
363 let report2 = scan(fixture, &opts).unwrap();
365 let loaded_baseline = BaselineFile::load(baseline_file.path()).unwrap();
366 let filtered: Vec<_> = report2
367 .findings
368 .into_iter()
369 .filter(|f| {
370 let fp = f.fingerprint(&report2.scan_root);
371 !loaded_baseline.contains(&fp)
372 })
373 .collect();
374
375 assert!(
377 filtered.is_empty(),
378 "All findings should be filtered by baseline, but {} remain: {:?}",
379 filtered.len(),
380 filtered.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
381 );
382 }
383
384 #[test]
385 fn suppress_command_roundtrip() {
386 use crate::config::Config;
387 use crate::rules::policy::Suppression;
388 use tempfile::TempDir;
389
390 let tmp = TempDir::new().unwrap();
392 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
393
394 let opts = ScanOptions::default();
396 let report = scan(fixture, &opts).unwrap();
397 assert!(
398 !report.findings.is_empty(),
399 "vuln_cmd_inject should produce findings"
400 );
401
402 let first_finding = &report.findings[0];
403 let fp = first_finding.fingerprint(&report.scan_root);
404 let rule_id = first_finding.rule_id.clone();
405
406 let config_path = tmp.path().join(".agentshield.toml");
408 let mut cfg = Config::default();
409 cfg.policy.suppressions.push(Suppression {
410 fingerprint: fp.clone(),
411 reason: "Integration test suppression".into(),
412 expires: None,
413 created_at: Some("2026-03-21".into()),
414 });
415 let toml_str = toml::to_string_pretty(&cfg).unwrap();
416 std::fs::write(&config_path, &toml_str).unwrap();
417
418 let loaded = Config::load(&config_path).unwrap();
420 assert_eq!(loaded.policy.suppressions.len(), 1);
421 assert_eq!(loaded.policy.suppressions[0].fingerprint, fp);
422 assert_eq!(
423 loaded.policy.suppressions[0].reason,
424 "Integration test suppression"
425 );
426
427 let opts_with_config = ScanOptions {
429 config_path: Some(config_path.clone()),
430 ..ScanOptions::default()
431 };
432 let report2 = scan(fixture, &opts_with_config).unwrap();
433
434 let still_present = report2
436 .findings
437 .iter()
438 .any(|f| f.rule_id == rule_id && f.fingerprint(&report2.scan_root) == fp);
439
440 assert!(
441 !still_present,
442 "Suppressed finding {} should not appear in re-scan",
443 fp
444 );
445 }
446
447 #[test]
455 fn dep_findings_location_parity_across_output_formats() {
456 use crate::output::OutputFormat;
457
458 let fixture = Path::new("tests/fixtures/mcp_servers/vuln_unpinned_deps");
459 let opts = ScanOptions::default();
460 let report = scan(fixture, &opts).unwrap();
461
462 let dep_finding = report
465 .findings
466 .iter()
467 .find(|f| f.rule_id == "SHIELD-009")
468 .expect("Expected at least one SHIELD-009 finding from vuln_unpinned_deps fixture");
469
470 let loc = dep_finding
472 .location
473 .as_ref()
474 .expect("SHIELD-009 finding must carry a manifest file location");
475 assert!(
476 loc.file.to_string_lossy().contains("requirements.txt"),
477 "SHIELD-009 location file should be requirements.txt, got: {}",
478 loc.file.display()
479 );
480 assert!(loc.line >= 1, "SHIELD-009 location line must be >= 1");
481
482 let expected_file = loc.file.to_string_lossy().to_string();
483
484 let console_out =
486 render_report(&report, OutputFormat::Console).expect("console render failed");
487 assert!(
488 console_out.contains("requirements.txt"),
489 "Console output should contain requirements.txt for dep findings"
490 );
491
492 let json_out = render_report(&report, OutputFormat::Json).expect("json render failed");
493 let json_val: serde_json::Value =
494 serde_json::from_str(&json_out).expect("JSON output must be valid JSON");
495 let json_findings = json_val["findings"]
496 .as_array()
497 .expect("JSON must have findings array");
498 let json_dep = json_findings
499 .iter()
500 .find(|f| f["rule_id"].as_str() == Some("SHIELD-009"))
501 .expect("JSON output must contain SHIELD-009 finding");
502 let json_file = json_dep["location"]["file"]
503 .as_str()
504 .expect("JSON SHIELD-009 finding must have location.file");
505 assert!(
506 json_file.contains("requirements.txt"),
507 "JSON location.file should contain requirements.txt, got: {json_file}"
508 );
509
510 let sarif_out = render_report(&report, OutputFormat::Sarif).expect("SARIF render failed");
511 let sarif_val: serde_json::Value =
512 serde_json::from_str(&sarif_out).expect("SARIF output must be valid JSON");
513 let sarif_results = sarif_val["runs"][0]["results"]
514 .as_array()
515 .expect("SARIF must have runs[0].results array");
516 let sarif_dep = sarif_results
517 .iter()
518 .find(|r| r["ruleId"].as_str() == Some("SHIELD-009"))
519 .expect(
520 "SARIF output must contain SHIELD-009 result (dep findings now have locations)",
521 );
522 let sarif_uri = sarif_dep["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
523 .as_str()
524 .expect("SARIF SHIELD-009 result must have a physicalLocation URI");
525 assert!(
526 sarif_uri.contains("requirements.txt"),
527 "SARIF artifactLocation URI should contain requirements.txt, got: {sarif_uri}"
528 );
529 assert!(
531 expected_file.contains("requirements.txt"),
532 "Location file {expected_file} must reference requirements.txt"
533 );
534
535 let html_out = render_report(&report, OutputFormat::Html).expect("HTML render failed");
536 assert!(
537 html_out.contains("requirements.txt"),
538 "HTML output should contain requirements.txt for dep findings"
539 );
540 assert!(
543 !html_out.contains("<code>-</code>"),
544 "HTML output must not show '-' for dep finding locations that have a manifest file"
545 );
546 }
547
548 #[test]
549 fn vuln_metadata_ssrf_detected() {
550 let opts = ScanOptions::default();
551 let report = scan(
552 Path::new("tests/fixtures/mcp_servers/vuln_metadata_ssrf"),
553 &opts,
554 )
555 .unwrap();
556 assert!(
560 report.findings.iter().any(|f| f.rule_id == "SHIELD-013"),
561 "Expected SHIELD-013 (metadata SSRF) from vuln_metadata_ssrf fixture"
562 );
563 assert!(
564 report.findings.iter().any(|f| f.rule_id == "SHIELD-003"),
565 "SHIELD-003 remains useful when the taint path has no concrete metadata URL"
566 );
567 assert!(!report.verdict.pass);
568 }
569
570 #[cfg(feature = "typescript")]
575 #[test]
576 fn safe_filesystem_no_file_access_findings() {
577 let opts = ScanOptions::default();
581 let report = scan(
582 Path::new("tests/fixtures/mcp_servers/safe_filesystem"),
583 &opts,
584 )
585 .unwrap();
586
587 let file_access_findings: Vec<_> = report
588 .findings
589 .iter()
590 .filter(|f| f.rule_id == "SHIELD-004")
591 .collect();
592
593 assert!(
594 file_access_findings.is_empty(),
595 "Expected 0 SHIELD-004 findings (cross-file sanitization should eliminate FPs), \
596 but got {}: {:?}",
597 file_access_findings.len(),
598 file_access_findings
599 .iter()
600 .map(|f| &f.message)
601 .collect::<Vec<_>>()
602 );
603 }
604}