diff --git a/CLAUDE.md b/CLAUDE.md index 179d92a..8d993f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ agentshield/ │ │ ├── finding.rs # Finding, Severity, Evidence structs │ │ ├── registry.rs # Rule metadata registry │ │ ├── policy.rs # Policy evaluation (.agentshield.toml) -│ │ └── builtin/ # 19 built-in detectors (SHIELD-001..019) +│ │ └── builtin/ # 20 built-in detectors (SHIELD-001..020) │ ├── output/ # Report formatters │ │ ├── mod.rs # OutputFormat enum, render() │ │ ├── console.rs # Plain text diff --git a/README.md b/README.md index 10b9c13..6b8cc63 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ reports. The current release line is `0.8.7`. | Area | What AgentShield does | |------|------------------------| | Scanner surface | Normalizes seven framework/client families into one IR: MCP, OpenClaw, Hermes Agent, CrewAI, LangChain/LangGraph, GPT Actions, and Cursor Rules. | -| Detection | Runs 19 built-in rules for command execution, credential exfiltration, capability mismatch, SSRF, filesystem risk, runtime installs, prompt surfaces, dependency hygiene, unsafe deserialization, secret leakage, and more. | +| Detection | Runs 20 built-in rules for command execution, credential exfiltration, composite toxic flows, capability mismatch, SSRF, filesystem risk, runtime installs, prompt surfaces, dependency hygiene, unsafe deserialization, secret leakage, and more. | | Workflow fit | Works locally, in CI, and in GitHub Code Scanning without sending source code to a hosted service. | | Boundary | AgentShield is not a hosted monitoring service, runtime sandbox, or allowlist marketplace. Experimental runtime guard entrypoints are available behind opt-in feature flags; the stable contract is static scanning plus policy evaluation. | diff --git a/docs/RULES.md b/docs/RULES.md index 109e0cc..65bb1bb 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -1,6 +1,6 @@ # Detection Rules -AgentShield ships with 19 built-in detectors targeting the most common security +AgentShield ships with 20 built-in detectors targeting the most common security issues in AI agent extensions. Each rule has an ID, severity, confidence level, and CWE mapping where applicable. @@ -439,6 +439,30 @@ honest natural-language description. --- +## SHIELD-020: Arbitrary Read Exfiltration Chain + +| Field | Value | +|-------|-------| +| Severity | High | +| CWE | [CWE-200](https://cwe.mitre.org/data/definitions/200.html) | +| Category | Data Exfiltration | +| OWASP MCP | MCP06 | + +**What it detects:** A complete, tool-scoped value-flow chain where a tool +argument controls a file-read path and the resulting file content reaches an +HTTP request payload. The detector uses the scanner's precomputed contextual +analysis and does not reparse source code. + +**Why it matters:** A malicious or overpowered tool can let an attacker select +a local file and send its contents to a network destination in one invocation. +SHIELD-004 may coexist to identify the arbitrary file access; SHIELD-020 +captures the additional exfiltration impact. + +**Remediation:** Restrict file reads to an allowlisted root, reject paths outside +that boundary, and never forward raw file contents to an untrusted endpoint. + +--- + ## Severity Levels | Level | Meaning | Default action | diff --git a/src/lib.rs b/src/lib.rs index e380256..a85f32c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -206,6 +206,41 @@ mod integration_tests { assert!(!report.verdict.pass); } + #[test] + #[cfg(feature = "typescript")] + fn vuln_read_exfil_chain_detected() { + let opts = ScanOptions::default(); + let report = scan( + Path::new("tests/fixtures/mcp_servers/vuln_read_exfil_chain"), + &opts, + ) + .unwrap(); + assert!( + report.findings.iter().any(|f| f.rule_id == "SHIELD-020"), + "Expected SHIELD-020 for read-and-send file exfiltration chain" + ); + assert!( + report.findings.iter().any(|f| f.rule_id == "SHIELD-004"), + "Expected SHIELD-004 to coexist with the composite finding" + ); + assert!(!report.verdict.pass); + } + + #[test] + #[cfg(not(feature = "typescript"))] + fn vuln_read_exfil_chain_is_not_emitted_without_typescript() { + let opts = ScanOptions::default(); + let report = scan( + Path::new("tests/fixtures/mcp_servers/vuln_read_exfil_chain"), + &opts, + ) + .unwrap(); + assert!( + !report.findings.iter().any(|f| f.rule_id == "SHIELD-020"), + "SHIELD-020 requires the TypeScript composite-flow analyzer" + ); + } + #[test] fn baseline_write_and_filter_round_trip() { use crate::baseline::{BaselineEntry, BaselineFile}; diff --git a/src/rules/builtin/composite_toxic_flow.rs b/src/rules/builtin/composite_toxic_flow.rs new file mode 100644 index 0000000..46bad10 --- /dev/null +++ b/src/rules/builtin/composite_toxic_flow.rs @@ -0,0 +1,310 @@ +use crate::analysis::composite_flow::{CompositeFlowCandidate, FlowEdgeKind, SemanticAnchor}; +use crate::analysis::DetectionInput; +use crate::ir::ScanTarget; +use crate::rules::{ + AttackCategory, Confidence, ContextDetector, Evidence, Finding, OwaspMcp, RuleMetadata, + Severity, +}; + +/// SHIELD-020: Arbitrary Read Exfiltration Chain. +pub struct ArbitraryReadExfiltrationDetector; + +impl ContextDetector for ArbitraryReadExfiltrationDetector { + fn metadata(&self) -> RuleMetadata { + RuleMetadata { + id: "SHIELD-020".into(), + name: "Arbitrary Read Exfiltration Chain".into(), + description: "File content read from controllable input is sent over the network" + .into(), + default_severity: Severity::High, + attack_category: AttackCategory::DataExfiltration, + cwe_id: Some("CWE-200".into()), + owasp_mcp: Some(OwaspMcp::DataExfiltration), + } + } + + fn run(&self, input: &DetectionInput<'_>) -> Vec { + input + .composite_flows + .iter() + .filter(|candidate| candidate.observation_complete) + .filter_map(|candidate| shield_020_finding(candidate, input.target)) + .collect() + } +} + +fn shield_020_finding(candidate: &CompositeFlowCandidate, target: &ScanTarget) -> Option { + let tool_name = target + .tools + .iter() + .find(|tool| tool.name == candidate.tool_name) + .map(|tool| tool.name.clone()) + .unwrap_or_else(|| candidate.tool_name.clone()); + + Some(Finding { + rule_id: "SHIELD-020".into(), + rule_name: "Arbitrary Read Exfiltration Chain".into(), + severity: Severity::High, + confidence: Confidence::High, + attack_category: AttackCategory::DataExfiltration, + message: format!( + "Tool '{}' can read an attacker-controlled file and send its contents over HTTP", + tool_name + ), + location: Some(candidate.sink_location.clone()), + evidence: finding_evidence(candidate), + taint_path: None, + remediation: Some( + "Validate file paths before reading and avoid sending raw file contents to untrusted sinks." + .into(), + ), + cwe_id: Some("CWE-200".into()), + }) +} + +fn finding_evidence(candidate: &CompositeFlowCandidate) -> Vec { + let mut evidence = Vec::with_capacity(candidate.edges.len() + 2); + evidence.push(Evidence { + description: format!( + "composite_flow:v1:arbitrary_read_exfiltration:{}:{}:{}", + candidate.tool_name, + format_anchor(&candidate.source_anchor), + format_anchor(&candidate.sink_anchor), + ), + location: Some(candidate.sink_location.clone()), + snippet: None, + }); + evidence.extend(candidate.edges.iter().map(|edge| Evidence { + description: chain_label(edge.kind).into(), + location: Some(edge.location.clone()), + snippet: None, + })); + evidence.push(Evidence { + description: "Related context: SHIELD-004 (arbitrary file access).".into(), + location: Some(candidate.source_location.clone()), + snippet: None, + }); + evidence +} + +fn chain_label(kind: FlowEdgeKind) -> &'static str { + match kind { + FlowEdgeKind::ControlsFilePath => "Tool argument controls the file path.", + FlowEdgeKind::ProducesFileContent => "File read produces the content value.", + FlowEdgeKind::Propagates => "The file content value propagates through an alias or helper.", + FlowEdgeKind::EntersNetworkPayload => { + "The network payload receives the same file content value." + } + } +} + +fn format_anchor(anchor: &SemanticAnchor) -> String { + format!( + "{}:{}:{}:{}:{}:{}", + anchor.relative_file.display(), + anchor.lexical_owner, + anchor.operation_kind, + anchor.resolved_api, + anchor.normalized_subtree_hash, + anchor.identical_ordinal, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::DetectionInput; + use crate::ir::{ExecutionSurface, SourceLocation}; + use crate::rules::ContextDetector; + use crate::rules::RuleEngine; + use std::path::{Path, PathBuf}; + + fn source_loc(file: &str, line: usize) -> SourceLocation { + SourceLocation { + file: PathBuf::from(file), + line, + column: 0, + end_line: None, + end_column: None, + } + } + + fn target_with_tool(name: &str) -> crate::ir::ScanTarget { + crate::ir::ScanTarget { + name: "fixture".into(), + framework: crate::ir::Framework::Mcp, + root_path: PathBuf::from("."), + tools: vec![crate::ir::ToolSurface { + name: name.into(), + description: Some("fixture tool".into()), + input_schema: None, + output_schema: None, + declared_permissions: Vec::new(), + defined_at: Some(source_loc("server.ts", 1)), + declared_capabilities: Default::default(), + capability_declarations: Vec::new(), + observed_capabilities: Default::default(), + capability_observation_complete: false, + capability_evidence: Vec::new(), + }], + execution: ExecutionSurface::default(), + data: Default::default(), + dependencies: Default::default(), + provenance: Default::default(), + source_files: vec![], + } + } + + fn anchor(file: &str, owner: &str, op: &'static str, api: &'static str) -> SemanticAnchor { + SemanticAnchor { + relative_file: PathBuf::from(file), + lexical_owner: owner.into(), + operation_kind: op, + resolved_api: api, + normalized_subtree_hash: String::new(), + identical_ordinal: 0, + } + } + + fn candidate(observation_complete: bool) -> CompositeFlowCandidate { + CompositeFlowCandidate { + tool_name: "read_and_send".into(), + source_location: source_loc("server.ts", 12), + sink_location: source_loc("server.ts", 17), + source_anchor: anchor("server.ts", "read_and_send", "file_read", "fs.read"), + sink_anchor: anchor("server.ts", "read_and_send", "network_payload", "fetch"), + edges: vec![ + edge(FlowEdgeKind::ControlsFilePath, 12), + edge(FlowEdgeKind::ProducesFileContent, 12), + edge(FlowEdgeKind::EntersNetworkPayload, 17), + ], + observation_complete, + } + } + + fn edge(kind: FlowEdgeKind, line: usize) -> crate::analysis::composite_flow::FlowEdge { + use crate::analysis::composite_flow::{ByteSpan, DefinitionId, FlowEdge, ScopeId, ValueId}; + + let value = ValueId { + definition: DefinitionId { + scope: ScopeId { + relative_file: PathBuf::from("server.ts"), + lexical_owner: "read_and_send".into(), + }, + definition_span: ByteSpan { + start: line, + end: line + 1, + }, + }, + version: 0, + }; + FlowEdge { + kind, + input: value.clone(), + output: value, + location: source_loc("server.ts", line), + } + } + + #[test] + fn emits_shield_020_for_complete_chain() { + let target = target_with_tool("read_and_send"); + let composite = vec![candidate(true)]; + let input = DetectionInput { + target: &target, + composite_flows: &composite, + }; + + let findings = ArbitraryReadExfiltrationDetector.run(&input); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].rule_id, "SHIELD-020"); + assert_eq!(findings[0].confidence, Confidence::High); + assert_eq!( + findings[0].message, + "Tool 'read_and_send' can read an attacker-controlled file and send its contents over HTTP" + ); + assert!(findings[0].evidence[0] + .description + .starts_with("composite_flow:v1:arbitrary_read_exfiltration:")); + assert!(findings[0] + .evidence + .iter() + .any(|ev| ev.description.contains("SHIELD-004"))); + } + + #[test] + fn ignores_incomplete_observation() { + let target = target_with_tool("read_and_send"); + let composite = vec![candidate(false)]; + let input = DetectionInput { + target: &target, + composite_flows: &composite, + }; + + let findings = ArbitraryReadExfiltrationDetector.run(&input); + assert!(findings.is_empty()); + } + + #[test] + fn appears_in_scanner_registry() { + let target = target_with_tool("read_and_send"); + let composite = vec![candidate(true)]; + let input = DetectionInput { + target: &target, + composite_flows: &composite, + }; + + let engine_findings = { + let engine = RuleEngine::new(); + engine.run_with_context(&input) + }; + + assert!(engine_findings + .iter() + .any(|finding| finding.rule_id == "SHIELD-020")); + + let engine = RuleEngine::new(); + assert!(!engine + .list_rules() + .iter() + .any(|metadata| metadata.id == "SHIELD-020")); + let metadata = engine + .list_scanner_rules() + .into_iter() + .find(|metadata| metadata.id == "SHIELD-020") + .expect("SHIELD-020 must appear in scanner metadata"); + assert_eq!(metadata.owasp_mcp, Some(OwaspMcp::DataExfiltration)); + } + + #[test] + fn fingerprint_uses_semantic_anchors_not_line_numbers() { + let target = target_with_tool("read_and_send"); + let mut original = candidate(true); + original.source_anchor.normalized_subtree_hash = "source-hash".into(); + original.sink_anchor.normalized_subtree_hash = "sink-hash".into(); + let original_finding = + shield_020_finding(&original, &target).expect("complete candidate emits"); + + let mut shifted = original.clone(); + shifted.source_location.line += 20; + shifted.sink_location.line += 20; + for edge in &mut shifted.edges { + edge.location.line += 20; + } + let shifted_finding = + shield_020_finding(&shifted, &target).expect("shifted candidate emits"); + assert_eq!( + original_finding.fingerprint(Path::new(".")), + shifted_finding.fingerprint(Path::new(".")) + ); + + let mut second_sink = original; + second_sink.sink_anchor.identical_ordinal = 1; + let second_finding = + shield_020_finding(&second_sink, &target).expect("second candidate emits"); + assert_ne!( + original_finding.fingerprint(Path::new(".")), + second_finding.fingerprint(Path::new(".")) + ); + } +} diff --git a/src/rules/builtin/mod.rs b/src/rules/builtin/mod.rs index 796022b..245bec6 100644 --- a/src/rules/builtin/mod.rs +++ b/src/rules/builtin/mod.rs @@ -2,6 +2,7 @@ mod arbitrary_file_access; mod archive_traversal; mod capability_mismatch; mod command_injection; +mod composite_toxic_flow; mod credential_exfil; mod download_exec; mod dynamic_exec; @@ -19,9 +20,9 @@ mod unpinned_deps; mod unsafe_deser; mod unsafe_deser_patterns; -use super::Detector; +use super::{ContextDetector, Detector}; -/// Returns all built-in detectors (19 rules: SHIELD-001..019). +/// Returns all built-in target-only detectors (SHIELD-001..019). pub fn all_detectors() -> Vec> { vec![ Box::new(command_injection::CommandInjectionDetector), @@ -45,3 +46,10 @@ pub fn all_detectors() -> Vec> { Box::new(capability_mismatch::CapabilityMismatchDetector), ] } + +/// Returns all built-in contextual scanners (not target-only). +pub(crate) fn all_context_detectors() -> Vec> { + vec![Box::new( + composite_toxic_flow::ArbitraryReadExfiltrationDetector, + )] +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs index d7ec3f4..0435a4b 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -35,7 +35,7 @@ impl RuleEngine { pub fn new() -> Self { Self { detectors: builtin::all_detectors(), - context_detectors: Vec::new(), + context_detectors: builtin::all_context_detectors(), } } @@ -46,7 +46,6 @@ impl RuleEngine { /// Run all built-in detectors, including contextual detectors. pub(crate) fn run_with_context(&self, input: &DetectionInput<'_>) -> Vec { - let _ = input.composite_flows.len(); let mut findings = self .detectors .iter() diff --git a/tests/fixtures/mcp_servers/vuln_read_exfil_chain/package.json b/tests/fixtures/mcp_servers/vuln_read_exfil_chain/package.json new file mode 100644 index 0000000..d9ba423 --- /dev/null +++ b/tests/fixtures/mcp_servers/vuln_read_exfil_chain/package.json @@ -0,0 +1,8 @@ +{ + "name": "vuln-read-exfil-chain", + "version": "1.0.0", + "description": "MCP server that reads a file and sends content to an endpoint", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + } +} diff --git a/tests/fixtures/mcp_servers/vuln_read_exfil_chain/server.ts b/tests/fixtures/mcp_servers/vuln_read_exfil_chain/server.ts new file mode 100644 index 0000000..a55863d --- /dev/null +++ b/tests/fixtures/mcp_servers/vuln_read_exfil_chain/server.ts @@ -0,0 +1,24 @@ +import { Server } from "@modelcontextprotocol/sdk/server"; +import { readFile } from "node:fs/promises"; + +const server = new Server({ + name: "vuln-read-exfil-chain", + version: "1.0.0", +}); + +async function readAndSend(path: string, url: string): Promise { + const content = await readFile(path, "utf8"); + await fetch(url, { + method: "POST", + body: content, + }); +} + +server.registerTool( + "read_and_send", + { + title: "Read and send", + description: "Read file and forward the contents", + }, + readAndSend, +);