Skip to main content

agentshield/
lib.rs

1//! AgentShield — Security scanner for AI agent extensions.
2//!
3//! Offline-first, multi-framework, SARIF output. Scans MCP servers,
4//! OpenClaw skills, and other agent extension formats for security issues.
5//!
6//! # Quick Start
7//!
8//! ```no_run
9//! use std::path::Path;
10//! use agentshield::{scan, ScanOptions};
11//!
12//! let options = ScanOptions::default();
13//! let report = scan(Path::new("./my-mcp-server"), &options).unwrap();
14//! println!("Pass: {}, Findings: {}", report.verdict.pass, report.findings.len());
15//! ```
16
17pub 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/// Options for a scan invocation.
45#[derive(Debug, Clone)]
46pub struct ScanOptions {
47    /// Path to config file (defaults to `.agentshield.toml` in scan dir).
48    pub config_path: Option<std::path::PathBuf>,
49    /// Preferred output format for callers that render the resulting [`ScanReport`].
50    pub format: OutputFormat,
51    /// CLI override for fail_on threshold.
52    pub fail_on_override: Option<rules::Severity>,
53    /// Skip test files (test/, tests/, *.test.ts, *.spec.ts, etc.).
54    pub ignore_tests: bool,
55    /// Optional directory containing custom declarative rules (*.yaml, *.yml, *.json).
56    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/// Complete scan report.
72#[derive(Debug)]
73pub struct ScanReport {
74    pub target_name: String,
75    pub findings: Vec<Finding>,
76    pub verdict: PolicyVerdict,
77    /// Absolute (or canonicalized) path to the scanned directory.
78    /// Passed to output renderers for stable fingerprint computation.
79    pub scan_root: std::path::PathBuf,
80    /// Raw scan targets produced by the adapter pipeline.
81    /// Used by callers that need to inspect the IR (e.g., `--emit-egress-policy`).
82    pub targets: Vec<ScanTarget>,
83    pub path_filter_summary: ScanPathFilterSummary,
84}
85
86impl ScanReport {
87    /// Render this report in the specified output format.
88    pub fn render(&self, format: OutputFormat) -> Result<String> {
89        render_report(self, format)
90    }
91}
92
93/// Run a complete scan: detect framework, parse, analyze, evaluate policy.
94pub fn scan(path: &Path, options: &ScanOptions) -> Result<ScanReport> {
95    scan_with_path_filter_overrides(path, options, &[], &[])
96}
97
98/// Run a complete scan with optional include/exclude patterns layered on top of
99/// the scan configuration.
100///
101/// The override form is used by the CLI so command-line path filters affect the
102/// adapter and detector execution, while keeping [`ScanOptions`] source
103/// compatible for library callers that construct it directly.
104pub fn scan_with_path_filter_overrides(
105    path: &Path,
106    options: &ScanOptions,
107    include_patterns: &[String],
108    exclude_patterns: &[String],
109) -> Result<ScanReport> {
110    // Load config
111    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    // Apply CLI path-filter overrides before constructing the filter used by
118    // adapters. This keeps execution and the reported filter summary aligned.
119    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    // Apply CLI override
127    if let Some(fail_on) = options.fail_on_override {
128        config.policy.fail_on = fail_on;
129    }
130
131    // Auto-detect framework and load IR
132    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    // Perform interprocedural call-graph taint propagation
138    crate::analysis::interprocedural::analyze_and_enrich_targets(&mut bundles);
139
140    // Run detectors on all targets (built-in + custom)
141    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    // Canonicalize for stable fingerprints; fall back to the raw path on error.
178    let scan_root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
179
180    // Apply policy (ignore rules, overrides, suppressions)
181    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
194/// Render a scan report in the specified format.
195pub 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
207/// Render a scan report with the opt-in experimental informational risk index.
208///
209/// This additive API supports console and JSON only. It does not change policy,
210/// the report verdict, process exit status, findings, or default rendering.
211pub 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        // No code-level security findings (SHIELD-001 through SHIELD-006, SHIELD-011)
242        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        // Step 1: scan and get findings
332        let report = scan(fixture, &opts).unwrap();
333        assert!(
334            !report.findings.is_empty(),
335            "vuln_cmd_inject should produce findings"
336        );
337
338        // Step 2: write baseline from findings
339        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        // Step 3: re-scan and filter with baseline
354        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        // Step 4: all findings should be filtered out
366        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        // Use a temp dir so we don't pollute fixture directories
381        let tmp = TempDir::new().unwrap();
382        let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
383
384        // Step 1: Scan the fixture to get a real fingerprint
385        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        // Step 2: Write a config with the suppression into a temp dir
397        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        // Step 3: Verify the config round-trips correctly
409        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        // Step 4: Re-scan using the config with the suppression
418        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        // The suppressed finding should no longer appear
425        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    /// Verify that dependency findings with manifest file locations appear
438    /// consistently across all 4 output formats (console, JSON, SARIF, HTML).
439    ///
440    /// Prior to T7, SHIELD-009 and SHIELD-012 had `location: None` and were
441    /// silently dropped from SARIF output while appearing as "-" in console
442    /// and HTML. Now that the adapter populates manifest file locations, all
443    /// formats must agree on the same location.
444    #[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        // The fixture has requirements.txt with >=versions and no lockfile.
453        // Expect at least one SHIELD-009 (Unpinned Dependencies) finding.
454        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        // The finding must have a location pointing to requirements.txt.
461        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        // Render all 4 formats and verify the location is present in each.
475        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        // Verify the URI matches the JSON location (both point to the same file)
520        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        // HTML must NOT show "-" for the dep finding location
531        // (the table shows the location as "file:line")
532        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        // The fixture has a tool that passes user-controlled `url` to requests.get.
547        // The taint path is a metadata-SSRF signal even without a literal URL,
548        // and the specific rule suppresses the generic SSRF overlap.
549        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    // The safe_filesystem fixture is TypeScript-only; without the typescript
561    // parser feature the .ts files are not parsed, cross-file sanitization
562    // cannot see validatePath(), and the helper file ops surface as false
563    // positives. Gate the test on the feature its premise depends on.
564    #[cfg(feature = "typescript")]
565    #[test]
566    fn safe_filesystem_no_file_access_findings() {
567        // This fixture has a handler that validates paths via validatePath()
568        // before passing them to helper functions. Cross-file analysis should
569        // downgrade the helpers' operations from tainted to sanitized.
570        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}