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        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    // Canonicalize for stable fingerprints; fall back to the raw path on error.
188    let scan_root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
189
190    // Apply policy (ignore rules, overrides, suppressions)
191    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
204/// Render a scan report in the specified format.
205pub 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
217/// Render a scan report with the opt-in experimental informational risk index.
218///
219/// This additive API supports console and JSON only. It does not change policy,
220/// the report verdict, process exit status, findings, or default rendering.
221pub 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        // No code-level security findings (SHIELD-001 through SHIELD-006, SHIELD-011)
252        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        // Step 1: scan and get findings
342        let report = scan(fixture, &opts).unwrap();
343        assert!(
344            !report.findings.is_empty(),
345            "vuln_cmd_inject should produce findings"
346        );
347
348        // Step 2: write baseline from findings
349        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        // Step 3: re-scan and filter with baseline
364        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        // Step 4: all findings should be filtered out
376        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        // Use a temp dir so we don't pollute fixture directories
391        let tmp = TempDir::new().unwrap();
392        let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
393
394        // Step 1: Scan the fixture to get a real fingerprint
395        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        // Step 2: Write a config with the suppression into a temp dir
407        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        // Step 3: Verify the config round-trips correctly
419        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        // Step 4: Re-scan using the config with the suppression
428        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        // The suppressed finding should no longer appear
435        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    /// Verify that dependency findings with manifest file locations appear
448    /// consistently across all 4 output formats (console, JSON, SARIF, HTML).
449    ///
450    /// Prior to T7, SHIELD-009 and SHIELD-012 had `location: None` and were
451    /// silently dropped from SARIF output while appearing as "-" in console
452    /// and HTML. Now that the adapter populates manifest file locations, all
453    /// formats must agree on the same location.
454    #[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        // The fixture has requirements.txt with >=versions and no lockfile.
463        // Expect at least one SHIELD-009 (Unpinned Dependencies) finding.
464        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        // The finding must have a location pointing to requirements.txt.
471        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        // Render all 4 formats and verify the location is present in each.
485        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        // Verify the URI matches the JSON location (both point to the same file)
530        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        // HTML must NOT show "-" for the dep finding location
541        // (the table shows the location as "file:line")
542        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        // The fixture has a tool that passes user-controlled `url` to requests.get.
557        // The taint path is a metadata-SSRF signal even without a literal URL,
558        // and the specific rule suppresses the generic SSRF overlap.
559        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    // The safe_filesystem fixture is TypeScript-only; without the typescript
571    // parser feature the .ts files are not parsed, cross-file sanitization
572    // cannot see validatePath(), and the helper file ops surface as false
573    // positives. Gate the test on the feature its premise depends on.
574    #[cfg(feature = "typescript")]
575    #[test]
576    fn safe_filesystem_no_file_access_findings() {
577        // This fixture has a handler that validates paths via validatePath()
578        // before passing them to helper functions. Cross-file analysis should
579        // downgrade the helpers' operations from tainted to sanitized.
580        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}