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