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 egress;
23pub mod error;
24pub mod ir;
25pub mod output;
26pub mod parser;
27pub mod rules;
28
29use std::path::Path;
30
31use config::Config;
32use error::Result;
33use ir::ScanTarget;
34use output::OutputFormat;
35use rules::policy::PolicyVerdict;
36use rules::{Finding, RuleEngine};
37
38/// Options for a scan invocation.
39#[derive(Debug, Clone)]
40pub struct ScanOptions {
41    /// Path to config file (defaults to `.agentshield.toml` in scan dir).
42    pub config_path: Option<std::path::PathBuf>,
43    /// Output format.
44    pub format: OutputFormat,
45    /// CLI override for fail_on threshold.
46    pub fail_on_override: Option<rules::Severity>,
47    /// Skip test files (test/, tests/, *.test.ts, *.spec.ts, etc.).
48    pub ignore_tests: bool,
49}
50
51impl Default for ScanOptions {
52    fn default() -> Self {
53        Self {
54            config_path: None,
55            format: OutputFormat::Console,
56            fail_on_override: None,
57            ignore_tests: false,
58        }
59    }
60}
61
62/// Complete scan report.
63#[derive(Debug)]
64pub struct ScanReport {
65    pub target_name: String,
66    pub findings: Vec<Finding>,
67    pub verdict: PolicyVerdict,
68    /// Absolute (or canonicalized) path to the scanned directory.
69    /// Passed to output renderers for stable fingerprint computation.
70    pub scan_root: std::path::PathBuf,
71    /// Raw scan targets produced by the adapter pipeline.
72    /// Used by callers that need to inspect the IR (e.g., `--emit-egress-policy`).
73    pub targets: Vec<ScanTarget>,
74}
75
76/// Run a complete scan: detect framework, parse, analyze, evaluate policy.
77pub fn scan(path: &Path, options: &ScanOptions) -> Result<ScanReport> {
78    // Load config
79    let config_path = options
80        .config_path
81        .clone()
82        .unwrap_or_else(|| path.join(".agentshield.toml"));
83    let mut config = Config::load(&config_path)?;
84
85    // Apply CLI override
86    if let Some(fail_on) = options.fail_on_override {
87        config.policy.fail_on = fail_on;
88    }
89
90    // Auto-detect framework and load IR
91    let ignore_tests = options.ignore_tests || config.scan.ignore_tests;
92    let targets = adapter::auto_detect_and_load(path, ignore_tests)?;
93
94    // Run detectors on all targets
95    let engine = RuleEngine::new();
96    let mut all_findings: Vec<Finding> = Vec::new();
97
98    let target_name = if let Some(first) = targets.first() {
99        first.name.clone()
100    } else {
101        path.file_name()
102            .map(|n| n.to_string_lossy().into_owned())
103            .unwrap_or_else(|| "unknown".into())
104    };
105
106    for target in &targets {
107        let findings = engine.run(target);
108        all_findings.extend(findings);
109    }
110
111    // Canonicalize for stable fingerprints; fall back to the raw path on error.
112    let scan_root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
113
114    // Apply policy (ignore rules, overrides, suppressions)
115    let effective_findings = config.policy.apply(&all_findings, &scan_root);
116    let verdict = config.policy.evaluate(&all_findings);
117
118    Ok(ScanReport {
119        target_name,
120        findings: effective_findings,
121        verdict,
122        scan_root,
123        targets,
124    })
125}
126
127/// Render a scan report in the specified format.
128pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
129    output::render(
130        &report.findings,
131        &report.verdict,
132        format,
133        &report.target_name,
134        &report.scan_root,
135    )
136}
137
138#[cfg(test)]
139mod integration_tests {
140    use super::*;
141    use std::path::Path;
142
143    #[test]
144    fn safe_calculator_zero_findings() {
145        let opts = ScanOptions::default();
146        let report = scan(
147            Path::new("tests/fixtures/mcp_servers/safe_calculator"),
148            &opts,
149        )
150        .unwrap();
151        // No code-level security findings (SHIELD-001 through SHIELD-006, SHIELD-011)
152        assert!(
153            !report
154                .findings
155                .iter()
156                .any(|f| f.severity >= rules::Severity::High),
157            "safe calculator should have no High+ findings"
158        );
159        assert!(report.verdict.pass);
160    }
161
162    #[test]
163    fn vuln_cmd_inject_detected() {
164        let opts = ScanOptions::default();
165        let report = scan(
166            Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject"),
167            &opts,
168        )
169        .unwrap();
170        assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-001"));
171        assert!(!report.verdict.pass);
172    }
173
174    #[test]
175    fn vuln_ssrf_detected() {
176        let opts = ScanOptions::default();
177        let report = scan(Path::new("tests/fixtures/mcp_servers/vuln_ssrf"), &opts).unwrap();
178        assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-003"));
179        assert!(!report.verdict.pass);
180    }
181
182    #[test]
183    fn vuln_cred_exfil_detected() {
184        let opts = ScanOptions::default();
185        let report = scan(
186            Path::new("tests/fixtures/mcp_servers/vuln_cred_exfil"),
187            &opts,
188        )
189        .unwrap();
190        assert!(report.findings.iter().any(|f| f.rule_id == "SHIELD-002"));
191        assert!(!report.verdict.pass);
192    }
193
194    #[test]
195    fn baseline_write_and_filter_round_trip() {
196        use crate::baseline::{BaselineEntry, BaselineFile};
197        use tempfile::NamedTempFile;
198
199        let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
200        let opts = ScanOptions::default();
201
202        // Step 1: scan and get findings
203        let report = scan(fixture, &opts).unwrap();
204        assert!(
205            !report.findings.is_empty(),
206            "vuln_cmd_inject should produce findings"
207        );
208
209        // Step 2: write baseline from findings
210        let baseline_file = NamedTempFile::new().unwrap();
211        let now = chrono::Utc::now().to_rfc3339();
212        let entries: Vec<BaselineEntry> = report
213            .findings
214            .iter()
215            .map(|f| BaselineEntry {
216                fingerprint: f.fingerprint(&report.scan_root),
217                rule_id: f.rule_id.clone(),
218                first_seen: now.clone(),
219            })
220            .collect();
221        let baseline = BaselineFile::new(entries);
222        baseline.save(baseline_file.path()).unwrap();
223
224        // Step 3: re-scan and filter with baseline
225        let report2 = scan(fixture, &opts).unwrap();
226        let loaded_baseline = BaselineFile::load(baseline_file.path()).unwrap();
227        let filtered: Vec<_> = report2
228            .findings
229            .into_iter()
230            .filter(|f| {
231                let fp = f.fingerprint(&report2.scan_root);
232                !loaded_baseline.contains(&fp)
233            })
234            .collect();
235
236        // Step 4: all findings should be filtered out
237        assert!(
238            filtered.is_empty(),
239            "All findings should be filtered by baseline, but {} remain: {:?}",
240            filtered.len(),
241            filtered.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
242        );
243    }
244
245    #[test]
246    fn suppress_command_roundtrip() {
247        use crate::config::Config;
248        use crate::rules::policy::Suppression;
249        use tempfile::TempDir;
250
251        // Use a temp dir so we don't pollute fixture directories
252        let tmp = TempDir::new().unwrap();
253        let fixture = Path::new("tests/fixtures/mcp_servers/vuln_cmd_inject");
254
255        // Step 1: Scan the fixture to get a real fingerprint
256        let opts = ScanOptions::default();
257        let report = scan(fixture, &opts).unwrap();
258        assert!(
259            !report.findings.is_empty(),
260            "vuln_cmd_inject should produce findings"
261        );
262
263        let first_finding = &report.findings[0];
264        let fp = first_finding.fingerprint(&report.scan_root);
265        let rule_id = first_finding.rule_id.clone();
266
267        // Step 2: Write a config with the suppression into a temp dir
268        let config_path = tmp.path().join(".agentshield.toml");
269        let mut cfg = Config::default();
270        cfg.policy.suppressions.push(Suppression {
271            fingerprint: fp.clone(),
272            reason: "Integration test suppression".into(),
273            expires: None,
274            created_at: Some("2026-03-21".into()),
275        });
276        let toml_str = toml::to_string_pretty(&cfg).unwrap();
277        std::fs::write(&config_path, &toml_str).unwrap();
278
279        // Step 3: Verify the config round-trips correctly
280        let loaded = Config::load(&config_path).unwrap();
281        assert_eq!(loaded.policy.suppressions.len(), 1);
282        assert_eq!(loaded.policy.suppressions[0].fingerprint, fp);
283        assert_eq!(
284            loaded.policy.suppressions[0].reason,
285            "Integration test suppression"
286        );
287
288        // Step 4: Re-scan using the config with the suppression
289        let opts_with_config = ScanOptions {
290            config_path: Some(config_path.clone()),
291            ..ScanOptions::default()
292        };
293        let report2 = scan(fixture, &opts_with_config).unwrap();
294
295        // The suppressed finding should no longer appear
296        let still_present = report2
297            .findings
298            .iter()
299            .any(|f| f.rule_id == rule_id && f.fingerprint(&report2.scan_root) == fp);
300
301        assert!(
302            !still_present,
303            "Suppressed finding {} should not appear in re-scan",
304            fp
305        );
306    }
307
308    /// Verify that dependency findings with manifest file locations appear
309    /// consistently across all 4 output formats (console, JSON, SARIF, HTML).
310    ///
311    /// Prior to T7, SHIELD-009 and SHIELD-012 had `location: None` and were
312    /// silently dropped from SARIF output while appearing as "-" in console
313    /// and HTML. Now that the adapter populates manifest file locations, all
314    /// formats must agree on the same location.
315    #[test]
316    fn dep_findings_location_parity_across_output_formats() {
317        use crate::output::OutputFormat;
318
319        let fixture = Path::new("tests/fixtures/mcp_servers/vuln_unpinned_deps");
320        let opts = ScanOptions::default();
321        let report = scan(fixture, &opts).unwrap();
322
323        // The fixture has requirements.txt with >=versions and no lockfile.
324        // Expect at least one SHIELD-009 (Unpinned Dependencies) finding.
325        let dep_finding = report
326            .findings
327            .iter()
328            .find(|f| f.rule_id == "SHIELD-009")
329            .expect("Expected at least one SHIELD-009 finding from vuln_unpinned_deps fixture");
330
331        // The finding must have a location pointing to requirements.txt.
332        let loc = dep_finding
333            .location
334            .as_ref()
335            .expect("SHIELD-009 finding must carry a manifest file location");
336        assert!(
337            loc.file.to_string_lossy().contains("requirements.txt"),
338            "SHIELD-009 location file should be requirements.txt, got: {}",
339            loc.file.display()
340        );
341        assert!(loc.line >= 1, "SHIELD-009 location line must be >= 1");
342
343        let expected_file = loc.file.to_string_lossy().to_string();
344
345        // Render all 4 formats and verify the location is present in each.
346        let console_out =
347            render_report(&report, OutputFormat::Console).expect("console render failed");
348        assert!(
349            console_out.contains("requirements.txt"),
350            "Console output should contain requirements.txt for dep findings"
351        );
352
353        let json_out = render_report(&report, OutputFormat::Json).expect("json render failed");
354        let json_val: serde_json::Value =
355            serde_json::from_str(&json_out).expect("JSON output must be valid JSON");
356        let json_findings = json_val["findings"]
357            .as_array()
358            .expect("JSON must have findings array");
359        let json_dep = json_findings
360            .iter()
361            .find(|f| f["rule_id"].as_str() == Some("SHIELD-009"))
362            .expect("JSON output must contain SHIELD-009 finding");
363        let json_file = json_dep["location"]["file"]
364            .as_str()
365            .expect("JSON SHIELD-009 finding must have location.file");
366        assert!(
367            json_file.contains("requirements.txt"),
368            "JSON location.file should contain requirements.txt, got: {json_file}"
369        );
370
371        let sarif_out = render_report(&report, OutputFormat::Sarif).expect("SARIF render failed");
372        let sarif_val: serde_json::Value =
373            serde_json::from_str(&sarif_out).expect("SARIF output must be valid JSON");
374        let sarif_results = sarif_val["runs"][0]["results"]
375            .as_array()
376            .expect("SARIF must have runs[0].results array");
377        let sarif_dep = sarif_results
378            .iter()
379            .find(|r| r["ruleId"].as_str() == Some("SHIELD-009"))
380            .expect(
381                "SARIF output must contain SHIELD-009 result (dep findings now have locations)",
382            );
383        let sarif_uri = sarif_dep["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
384            .as_str()
385            .expect("SARIF SHIELD-009 result must have a physicalLocation URI");
386        assert!(
387            sarif_uri.contains("requirements.txt"),
388            "SARIF artifactLocation URI should contain requirements.txt, got: {sarif_uri}"
389        );
390        // Verify the URI matches the JSON location (both point to the same file)
391        assert!(
392            expected_file.contains("requirements.txt"),
393            "Location file {expected_file} must reference requirements.txt"
394        );
395
396        let html_out = render_report(&report, OutputFormat::Html).expect("HTML render failed");
397        assert!(
398            html_out.contains("requirements.txt"),
399            "HTML output should contain requirements.txt for dep findings"
400        );
401        // HTML must NOT show "-" for the dep finding location
402        // (the table shows the location as "file:line")
403        assert!(
404            !html_out.contains("<code>-</code>"),
405            "HTML output must not show '-' for dep finding locations that have a manifest file"
406        );
407    }
408
409    #[test]
410    fn vuln_metadata_ssrf_detected() {
411        let opts = ScanOptions::default();
412        let report = scan(
413            Path::new("tests/fixtures/mcp_servers/vuln_metadata_ssrf"),
414            &opts,
415        )
416        .unwrap();
417        // The fixture has a tool that passes user-controlled `url` to requests.get,
418        // which should trigger SHIELD-003 (general SSRF) and potentially SHIELD-013
419        // (metadata SSRF) via taint paths if populated.
420        // At minimum, SHIELD-003 must fire (parameter -> network call).
421        assert!(
422            report.findings.iter().any(|f| f.rule_id == "SHIELD-003"),
423            "Expected SHIELD-003 (general SSRF) from vuln_metadata_ssrf fixture"
424        );
425        assert!(!report.verdict.pass);
426    }
427
428    #[test]
429    fn safe_filesystem_no_file_access_findings() {
430        // This fixture has a handler that validates paths via validatePath()
431        // before passing them to helper functions. Cross-file analysis should
432        // downgrade the helpers' operations from tainted to sanitized.
433        let opts = ScanOptions::default();
434        let report = scan(
435            Path::new("tests/fixtures/mcp_servers/safe_filesystem"),
436            &opts,
437        )
438        .unwrap();
439
440        let file_access_findings: Vec<_> = report
441            .findings
442            .iter()
443            .filter(|f| f.rule_id == "SHIELD-004")
444            .collect();
445
446        assert!(
447            file_access_findings.is_empty(),
448            "Expected 0 SHIELD-004 findings (cross-file sanitization should eliminate FPs), \
449             but got {}: {:?}",
450            file_access_findings.len(),
451            file_access_findings
452                .iter()
453                .map(|f| &f.message)
454                .collect::<Vec<_>>()
455        );
456    }
457}