1use super::*;
10use std::collections::HashSet;
11use std::path::Path;
12
13use mockforge_plugin_core::{
15 FilesystemPermissions, NetworkPermissions, PluginCapabilities, PluginId, PluginManifest,
16 PluginVersion, ResourceLimits,
17};
18
19use wasmparser::{Parser, Payload};
21
22use ring::signature;
24
25use shellexpand;
27
28use base64::{engine::general_purpose, Engine as _};
30use hex;
31
32use serde_json;
34
35#[derive(Debug, Clone)]
37struct PluginSignature {
38 algorithm: String,
39 signature: Vec<u8>,
40 key_id: String,
41}
42
43pub struct PluginValidator {
45 config: PluginLoaderConfig,
47 security_policies: SecurityPolicies,
49}
50
51impl PluginValidator {
52 pub fn new(config: PluginLoaderConfig) -> Self {
54 Self {
55 config,
56 security_policies: SecurityPolicies::default(),
57 }
58 }
59
60 pub async fn validate_manifest(&self, manifest: &PluginManifest) -> LoaderResult<()> {
62 let mut errors = Vec::new();
63
64 if let Err(validation_error) = manifest.validate() {
66 errors.push(PluginLoaderError::manifest(validation_error));
67 }
68
69 if let Err(e) = self.security_policies.validate_manifest(manifest) {
71 errors.push(e);
72 }
73
74 let mut visited = HashSet::new();
76 visited.insert(manifest.info.id.clone());
77 if let Err(e) = self
78 .validate_dependencies(&manifest.info.id, &manifest.dependencies, &mut visited)
79 .await
80 {
81 errors.push(e);
82 }
83
84 if errors.is_empty() {
85 Ok(())
86 } else {
87 Err(PluginLoaderError::validation(format!(
88 "Manifest validation failed with {} errors: {}",
89 errors.len(),
90 errors.into_iter().map(|e| e.to_string()).collect::<Vec<_>>().join(", ")
91 )))
92 }
93 }
94
95 pub fn validate_capabilities(&self, capability_names: &[String]) -> LoaderResult<()> {
97 let capabilities = PluginCapabilities {
99 network: NetworkPermissions::default(),
100 filesystem: FilesystemPermissions::default(),
101 resources: ResourceLimits::default(),
102 custom: capability_names
103 .iter()
104 .map(|name| (name.clone(), serde_json::Value::Bool(true)))
105 .collect(),
106 };
107 self.security_policies.validate_capabilities(&capabilities)
108 }
109
110 pub async fn validate_wasm_file(&self, wasm_path: &Path) -> LoaderResult<()> {
112 if !wasm_path.exists() {
114 return Err(PluginLoaderError::fs("WASM file does not exist".to_string()));
115 }
116
117 let metadata = tokio::fs::metadata(wasm_path)
118 .await
119 .map_err(|e| PluginLoaderError::fs(format!("Cannot read WASM file metadata: {}", e)))?;
120
121 if !metadata.is_file() {
122 return Err(PluginLoaderError::fs("WASM path is not a file".to_string()));
123 }
124
125 let file_size = metadata.len();
127 if file_size > self.security_policies.max_wasm_file_size {
128 return Err(PluginLoaderError::security(format!(
129 "WASM file too large: {} bytes (max: {} bytes)",
130 file_size, self.security_policies.max_wasm_file_size
131 )));
132 }
133
134 self.validate_wasm_module(wasm_path).await?;
136
137 Ok(())
138 }
139
140 pub async fn validate_plugin_file(&self, plugin_path: &Path) -> LoaderResult<PluginManifest> {
142 if !plugin_path.exists() {
143 return Err(PluginLoaderError::fs("Plugin path does not exist".to_string()));
144 }
145
146 if !plugin_path.is_dir() {
147 return Err(PluginLoaderError::fs("Plugin path must be a directory".to_string()));
148 }
149
150 let manifest_path = plugin_path.join("plugin.yaml");
152 if !manifest_path.exists() {
153 return Err(PluginLoaderError::manifest("plugin.yaml not found".to_string()));
154 }
155
156 let manifest = PluginManifest::from_file(&manifest_path)
158 .map_err(|e| PluginLoaderError::manifest(format!("Failed to load manifest: {}", e)))?;
159
160 self.validate_manifest(&manifest).await?;
162
163 let wasm_files: Vec<_> = std::fs::read_dir(plugin_path)
165 .map_err(|e| PluginLoaderError::fs(format!("Cannot read plugin directory: {}", e)))?
166 .filter_map(|entry| entry.ok())
167 .map(|entry| entry.path())
168 .filter(|path| path.extension().is_some_and(|ext| ext == "wasm"))
169 .collect();
170
171 if wasm_files.is_empty() {
172 return Err(PluginLoaderError::load(
173 "No WebAssembly file found in plugin directory".to_string(),
174 ));
175 }
176
177 if wasm_files.len() > 1 {
178 return Err(PluginLoaderError::load(
179 "Multiple WebAssembly files found in plugin directory".to_string(),
180 ));
181 }
182
183 if !self.config.skip_wasm_validation {
185 self.validate_wasm_file(&wasm_files[0]).await?;
186 }
187
188 Ok(manifest)
189 }
190
191 async fn validate_dependencies(
193 &self,
194 current_plugin_id: &PluginId,
195 dependencies: &std::collections::HashMap<mockforge_plugin_core::PluginId, PluginVersion>,
196 visited: &mut HashSet<PluginId>,
197 ) -> LoaderResult<()> {
198 for (plugin_id, version) in dependencies {
199 if self.would_create_circular_dependency(current_plugin_id, plugin_id, visited) {
201 return Err(PluginLoaderError::ValidationError {
202 message: format!(
203 "Circular dependency detected: '{}' -> '{}'",
204 current_plugin_id.0, plugin_id.0
205 ),
206 });
207 }
208
209 if plugin_id.0.is_empty() {
211 return Err(PluginLoaderError::ValidationError {
212 message: "Dependency plugin ID cannot be empty".to_string(),
213 });
214 }
215
216 if plugin_id.0.len() > 100 {
217 return Err(PluginLoaderError::ValidationError {
218 message: format!(
219 "Dependency plugin ID '{}' is too long (max 100 characters)",
220 plugin_id.0
221 ),
222 });
223 }
224
225 if version.major == 0 && version.minor == 0 && version.patch == 0 {
227 tracing::warn!("Dependency '{}' specifies version 0.0.0 which may indicate development/testing", plugin_id.0);
228 }
229
230 if plugin_id.0.contains("..") || plugin_id.0.contains("/") || plugin_id.0.contains("\\")
232 {
233 return Err(PluginLoaderError::SecurityViolation {
234 violation: format!(
235 "Dependency plugin ID '{}' contains potentially unsafe characters",
236 plugin_id.0
237 ),
238 });
239 }
240
241 }
247
248 Ok(())
249 }
250
251 fn would_create_circular_dependency(
255 &self,
256 current_plugin_id: &PluginId,
257 dependency_id: &PluginId,
258 visited: &mut HashSet<PluginId>,
259 ) -> bool {
260 if dependency_id == current_plugin_id {
262 return true;
263 }
264
265 visited.contains(dependency_id)
271 }
272
273 async fn validate_wasm_module(&self, wasm_path: &Path) -> LoaderResult<()> {
275 let bytes = tokio::fs::read(wasm_path)
276 .await
277 .map_err(|e| PluginLoaderError::fs(format!("Cannot read WASM module: {e}")))?;
278 self.security_policies.validate_wasm_binary(&bytes)
279 }
280
281 pub async fn validate_plugin_signature(
283 &self,
284 plugin_path: &Path,
285 manifest: &PluginManifest,
286 ) -> LoaderResult<()> {
287 if self.config.allow_unsigned {
289 return Ok(());
290 }
291
292 let sig_path = plugin_path.with_extension("sig");
294 if !sig_path.exists() {
295 return Err(PluginLoaderError::SecurityViolation {
296 violation: format!(
297 "Plugin '{}' requires a signature but none was found",
298 manifest.info.id.0
299 ),
300 });
301 }
302
303 let signature_data =
305 std::fs::read(&sig_path).map_err(|e| PluginLoaderError::ValidationError {
306 message: format!("Failed to read signature file: {}", e),
307 })?;
308
309 let signature = self.parse_signature(&signature_data)?;
311
312 let plugin_data =
314 std::fs::read(plugin_path).map_err(|e| PluginLoaderError::ValidationError {
315 message: format!("Failed to read plugin file: {}", e),
316 })?;
317
318 self.verify_signature(&plugin_data, &signature, manifest).await?;
320
321 Ok(())
322 }
323
324 fn parse_signature(&self, data: &[u8]) -> Result<PluginSignature, PluginLoaderError> {
326 let sig_json: serde_json::Value =
330 serde_json::from_slice(data).map_err(|e| PluginLoaderError::ValidationError {
331 message: format!("Invalid signature JSON format: {}", e),
332 })?;
333
334 let algorithm = sig_json
335 .get("algorithm")
336 .and_then(|v| v.as_str())
337 .ok_or_else(|| PluginLoaderError::ValidationError {
338 message: "Missing or invalid 'algorithm' field".to_string(),
339 })?
340 .to_string();
341
342 let signature_hex =
343 sig_json.get("signature").and_then(|v| v.as_str()).ok_or_else(|| {
344 PluginLoaderError::ValidationError {
345 message: "Missing or invalid 'signature' field".to_string(),
346 }
347 })?;
348
349 let key_id = sig_json
350 .get("key_id")
351 .and_then(|v| v.as_str())
352 .ok_or_else(|| PluginLoaderError::ValidationError {
353 message: "Missing or invalid 'key_id' field".to_string(),
354 })?
355 .to_string();
356
357 if !["rsa", "ecdsa", "ed25519"].contains(&algorithm.as_str()) {
359 return Err(PluginLoaderError::ValidationError {
360 message: format!("Unsupported signature algorithm: {}", algorithm),
361 });
362 }
363
364 let signature =
366 hex::decode(signature_hex).map_err(|e| PluginLoaderError::ValidationError {
367 message: format!("Invalid signature hex: {}", e),
368 })?;
369
370 Ok(PluginSignature {
371 algorithm,
372 signature,
373 key_id,
374 })
375 }
376
377 async fn verify_signature(
379 &self,
380 data: &[u8],
381 signature: &PluginSignature,
382 manifest: &PluginManifest,
383 ) -> LoaderResult<()> {
384 let public_key = self.get_trusted_key(&signature.key_id)?;
386
387 match signature.algorithm.as_str() {
389 "rsa" => {
390 self.verify_rsa_signature(data, &signature.signature, &public_key)?;
392 }
393 "ecdsa" => {
394 self.verify_ecdsa_signature(data, &signature.signature, &public_key)?;
396 }
397 "ed25519" => {
398 self.verify_ed25519_signature(data, &signature.signature, &public_key)?;
400 }
401 _ => {
402 return Err(PluginLoaderError::ValidationError {
403 message: format!("Unsupported algorithm: {}", signature.algorithm),
404 });
405 }
406 }
407
408 self.validate_key_authorization(&signature.key_id, manifest)?;
410
411 Ok(())
412 }
413
414 fn get_trusted_key(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
416 if !self.config.trusted_keys.contains(&key_id.to_string()) {
418 return Err(PluginLoaderError::SecurityViolation {
419 violation: format!("Key '{}' is not in the trusted keys list", key_id),
420 });
421 }
422
423 self.load_key_from_store(key_id)
426 }
427
428 fn load_key_from_store(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
430 if let Ok(key_data) = self.load_key_from_env(key_id) {
432 tracing::info!("Loaded key '{}' from environment variable", key_id);
433 return Ok(key_data);
434 }
435
436 if let Some(key_data) = self.config.key_data.get(key_id) {
438 tracing::info!("Loaded key '{}' from configuration", key_id);
439 return Ok(key_data.clone());
440 }
441
442 if let Ok(key_data) = self.load_key_from_filesystem(key_id) {
444 tracing::info!("Loaded key '{}' from filesystem", key_id);
445 return Ok(key_data);
446 }
447
448 if let Ok(key_data) = self.load_key_from_database(key_id) {
451 tracing::info!("Loaded key '{}' from database provider", key_id);
452 return Ok(key_data);
453 }
454
455 if let Ok(key_data) = self.load_key_from_kms(key_id) {
457 tracing::info!("Loaded key '{}' from key management service", key_id);
458 return Ok(key_data);
459 }
460
461 Err(PluginLoaderError::SecurityViolation {
462 violation: format!("Could not find key data for trusted key: {}", key_id),
463 })
464 }
465
466 fn load_key_from_env(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
468 self.load_key_material_from_prefixes(key_id, &["MOCKFORGE_KEY"], "environment")
469 }
470
471 fn load_key_material_from_prefixes(
472 &self,
473 key_id: &str,
474 prefixes: &[&str],
475 source_name: &str,
476 ) -> Result<Vec<u8>, PluginLoaderError> {
477 let normalized = key_id.to_uppercase().replace("-", "_");
478
479 for prefix in prefixes {
480 let b64_env_key = format!("{}_{}_B64", prefix, normalized);
482 if let Ok(b64_value) = std::env::var(&b64_env_key) {
483 match general_purpose::STANDARD.decode(&b64_value) {
484 Ok(key_data) => return Ok(key_data),
485 Err(e) => {
486 tracing::warn!(
487 "Failed to decode base64 key from {} ({}): {}",
488 b64_env_key,
489 source_name,
490 e
491 );
492 }
493 }
494 }
495
496 let hex_env_key = format!("{}_{}_HEX", prefix, normalized);
498 if let Ok(hex_value) = std::env::var(&hex_env_key) {
499 match hex::decode(&hex_value) {
500 Ok(key_data) => return Ok(key_data),
501 Err(e) => {
502 tracing::warn!(
503 "Failed to decode hex key from {} ({}): {}",
504 hex_env_key,
505 source_name,
506 e
507 );
508 }
509 }
510 }
511
512 let raw_env_key = format!("{}_{}", prefix, normalized);
514 if let Ok(key_data) = std::env::var(&raw_env_key) {
515 return Ok(key_data.into_bytes());
516 }
517 }
518
519 Err(PluginLoaderError::SecurityViolation {
520 violation: format!("Key not found in {}: {}", source_name, key_id),
521 })
522 }
523
524 fn load_key_from_directory(
525 &self,
526 key_id: &str,
527 dir: &std::path::Path,
528 ) -> Result<Vec<u8>, PluginLoaderError> {
529 let candidates = [
530 dir.join(format!("{}.der", key_id)),
531 dir.join(format!("{}.pem", key_id)),
532 dir.join(format!("{}.key", key_id)),
533 dir.join(format!("{}.bin", key_id)),
534 ];
535
536 for path in candidates {
537 if path.exists() {
538 return std::fs::read(&path).map_err(|e| PluginLoaderError::SecurityViolation {
539 violation: format!("Failed to read key file {}: {}", path.display(), e),
540 });
541 }
542 }
543
544 Err(PluginLoaderError::SecurityViolation {
545 violation: format!("Key '{}' not found in directory {}", key_id, dir.display()),
546 })
547 }
548
549 fn load_key_from_filesystem(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
551 let key_paths = vec![
552 format!("~/.mockforge/keys/{}.der", key_id),
553 format!("~/.mockforge/keys/{}.pem", key_id),
554 format!("/etc/mockforge/keys/{}.der", key_id),
555 format!("/etc/mockforge/keys/{}.pem", key_id),
556 ];
557
558 for key_path in key_paths {
559 let expanded_path = shellexpand::tilde(&key_path);
560 let path = std::path::Path::new(expanded_path.as_ref());
561
562 if path.exists() {
563 match std::fs::read(path) {
564 Ok(key_data) => return Ok(key_data),
565 Err(e) => {
566 tracing::warn!("Failed to read key file {}: {}", path.display(), e);
567 continue;
568 }
569 }
570 }
571 }
572
573 Err(PluginLoaderError::SecurityViolation {
574 violation: format!("Key not found in filesystem: {}", key_id),
575 })
576 }
577
578 #[allow(unused)]
584 fn load_key_from_database(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
585 let db_type = std::env::var("MOCKFORGE_DB_TYPE").map_err(|_| {
586 PluginLoaderError::SecurityViolation {
587 violation: "Database key loading requires MOCKFORGE_DB_TYPE environment variable"
588 .to_string(),
589 }
590 })?;
591
592 let connection_string = std::env::var("MOCKFORGE_DB_CONNECTION").map_err(|_| {
593 PluginLoaderError::SecurityViolation {
594 violation:
595 "Database key loading requires MOCKFORGE_DB_CONNECTION environment variable"
596 .to_string(),
597 }
598 })?;
599
600 let table_name =
601 std::env::var("MOCKFORGE_DB_KEY_TABLE").unwrap_or_else(|_| "plugin_keys".to_string());
602
603 tracing::info!("Database key loading configured: type={}, table={}", db_type, table_name);
604 tracing::debug!("Looking up key '{}' in database-backed key source", key_id);
605
606 if let Ok(key_data) =
607 self.load_key_material_from_prefixes(key_id, &["MOCKFORGE_DB_KEY"], "database env")
608 {
609 return Ok(key_data);
610 }
611
612 if let Ok(key_dir) = std::env::var("MOCKFORGE_DB_KEY_DIR") {
613 let expanded = shellexpand::tilde(&key_dir);
614 let path = std::path::Path::new(expanded.as_ref());
615 if path.exists() {
616 return self.load_key_from_directory(key_id, path);
617 }
618 }
619
620 Err(PluginLoaderError::SecurityViolation {
621 violation: format!(
622 "Database key '{}' not found in configured environment or key directory (connection: {})",
623 key_id, connection_string
624 ),
625 })
626 }
627
628 #[allow(unused)]
635 fn load_key_from_kms(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
636 let kms_provider = std::env::var("MOCKFORGE_KMS_PROVIDER").map_err(|_| {
637 PluginLoaderError::SecurityViolation {
638 violation: "KMS key loading requires MOCKFORGE_KMS_PROVIDER environment variable"
639 .to_string(),
640 }
641 })?;
642
643 match kms_provider.to_lowercase().as_str() {
644 "aws" => self.load_key_from_aws_kms(key_id),
645 "vault" => self.load_key_from_vault(key_id),
646 "azure" => self.load_key_from_azure_kv(key_id),
647 "gcp" => self.load_key_from_gcp_kms(key_id),
648 _ => Err(PluginLoaderError::SecurityViolation {
649 violation: format!("Unsupported KMS provider: {}", kms_provider),
650 }),
651 }
652 }
653
654 fn load_key_from_aws_kms(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
656 let region =
657 std::env::var("MOCKFORGE_KMS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
658
659 tracing::info!("AWS KMS key loading configured: region={}", region);
660 tracing::debug!("Looking up key '{}' in AWS KMS", key_id);
661
662 self.load_key_material_from_prefixes(
663 key_id,
664 &["MOCKFORGE_AWS_KMS_KEY", "MOCKFORGE_KMS_KEY"],
665 "AWS KMS",
666 )
667 }
668
669 fn load_key_from_vault(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
671 let vault_addr = std::env::var("MOCKFORGE_VAULT_ADDR").map_err(|_| {
672 PluginLoaderError::SecurityViolation {
673 violation: "Vault key loading requires MOCKFORGE_VAULT_ADDR environment variable"
674 .to_string(),
675 }
676 })?;
677
678 let _vault_token = std::env::var("MOCKFORGE_VAULT_TOKEN").map_err(|_| {
679 PluginLoaderError::SecurityViolation {
680 violation: "Vault key loading requires MOCKFORGE_VAULT_TOKEN environment variable"
681 .to_string(),
682 }
683 })?;
684
685 tracing::info!("HashCorp Vault key loading configured: addr={}", vault_addr);
686 tracing::debug!("Looking up key '{}' in Vault", key_id);
687
688 self.load_key_material_from_prefixes(
689 key_id,
690 &["MOCKFORGE_VAULT_KEY", "MOCKFORGE_KMS_KEY"],
691 "Vault",
692 )
693 }
694
695 fn load_key_from_azure_kv(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
697 tracing::info!("Azure Key Vault key loading requested");
698 tracing::debug!("Looking up key '{}' in Azure Key Vault", key_id);
699
700 self.load_key_material_from_prefixes(
701 key_id,
702 &["MOCKFORGE_AZURE_KV_KEY", "MOCKFORGE_KMS_KEY"],
703 "Azure Key Vault",
704 )
705 }
706
707 fn load_key_from_gcp_kms(&self, key_id: &str) -> Result<Vec<u8>, PluginLoaderError> {
709 tracing::info!("Google Cloud KMS key loading requested");
710 tracing::debug!("Looking up key '{}' in GCP KMS", key_id);
711
712 self.load_key_material_from_prefixes(
713 key_id,
714 &["MOCKFORGE_GCP_KMS_KEY", "MOCKFORGE_KMS_KEY"],
715 "Google Cloud KMS",
716 )
717 }
718
719 fn verify_rsa_signature(
721 &self,
722 data: &[u8],
723 signature: &[u8],
724 public_key: &[u8],
725 ) -> LoaderResult<()> {
726 let public_key =
728 signature::UnparsedPublicKey::new(&signature::RSA_PKCS1_2048_8192_SHA256, public_key);
729
730 public_key
732 .verify(data, signature)
733 .map_err(|e| PluginLoaderError::SecurityViolation {
734 violation: format!("RSA signature verification failed: {}", e),
735 })?;
736
737 Ok(())
738 }
739
740 fn verify_ecdsa_signature(
742 &self,
743 data: &[u8],
744 signature: &[u8],
745 public_key: &[u8],
746 ) -> LoaderResult<()> {
747 let public_key =
749 signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, public_key);
750
751 public_key
753 .verify(data, signature)
754 .map_err(|e| PluginLoaderError::SecurityViolation {
755 violation: format!("ECDSA signature verification failed: {}", e),
756 })?;
757
758 Ok(())
759 }
760
761 fn verify_ed25519_signature(
763 &self,
764 data: &[u8],
765 signature: &[u8],
766 public_key: &[u8],
767 ) -> LoaderResult<()> {
768 let public_key = signature::UnparsedPublicKey::new(&signature::ED25519, public_key);
770
771 public_key
773 .verify(data, signature)
774 .map_err(|e| PluginLoaderError::SecurityViolation {
775 violation: format!("Ed25519 signature verification failed: {}", e),
776 })?;
777
778 Ok(())
779 }
780
781 fn validate_key_authorization(
783 &self,
784 key_id: &str,
785 manifest: &PluginManifest,
786 ) -> LoaderResult<()> {
787 if self.config.trusted_keys.contains(&key_id.to_string()) {
789 return Ok(());
790 }
791
792 Err(PluginLoaderError::SecurityViolation {
793 violation: format!(
794 "Key '{}' is not authorized to sign plugins from '{}'",
795 key_id, manifest.info.author.name
796 ),
797 })
798 }
799
800 pub async fn get_validation_summary(&self, plugin_path: &Path) -> ValidationSummary {
802 let mut summary = ValidationSummary::default();
803
804 if !plugin_path.exists() {
806 summary.errors.push("Plugin path does not exist".to_string());
807 return summary;
808 }
809
810 let manifest_result = self.validate_plugin_file(plugin_path).await;
812 match manifest_result {
813 Ok(manifest) => {
814 summary.manifest_valid = true;
815 summary.manifest = Some(manifest);
816 }
817 Err(e) => {
818 summary.errors.push(format!("Manifest validation failed: {}", e));
819 }
820 }
821
822 if let Ok(wasm_path) = self.find_wasm_file(plugin_path) {
824 let wasm_result = self.validate_wasm_file(&wasm_path).await;
825 summary.wasm_valid = wasm_result.is_ok();
826 if let Err(e) = wasm_result {
827 summary.errors.push(format!("WASM validation failed: {}", e));
828 }
829 } else {
830 summary.errors.push("No WebAssembly file found".to_string());
831 }
832
833 summary.is_valid =
834 summary.manifest_valid && summary.wasm_valid && summary.errors.is_empty();
835 summary
836 }
837
838 fn find_wasm_file(&self, plugin_path: &Path) -> LoaderResult<PathBuf> {
840 let entries = std::fs::read_dir(plugin_path)
841 .map_err(|e| PluginLoaderError::fs(format!("Cannot read directory: {}", e)))?;
842
843 for entry in entries {
844 let entry =
845 entry.map_err(|e| PluginLoaderError::fs(format!("Cannot read entry: {}", e)))?;
846 let path = entry.path();
847
848 if let Some(extension) = path.extension() {
849 if extension == "wasm" {
850 return Ok(path);
851 }
852 }
853 }
854
855 Err(PluginLoaderError::load("No WebAssembly file found".to_string()))
856 }
857}
858
859#[derive(Debug, Clone)]
861pub struct SecurityPolicies {
862 pub max_wasm_file_size: u64,
864 pub allowed_imports: HashSet<String>,
866 pub forbidden_imports: HashSet<String>,
868 pub max_memory_pages: u32,
870 pub max_functions: u32,
872 pub allow_floats: bool,
874 pub allow_simd: bool,
876 pub allow_network_access: bool,
878 pub allow_filesystem_read: bool,
880 pub allow_filesystem_write: bool,
882}
883
884impl Default for SecurityPolicies {
885 fn default() -> Self {
886 let mut allowed_imports = HashSet::new();
887 allowed_imports.insert("env".to_string());
888 allowed_imports.insert("wasi_snapshot_preview1".to_string());
889
890 let mut forbidden_imports = HashSet::new();
891 forbidden_imports.insert("abort".to_string());
892 forbidden_imports.insert("exit".to_string());
893
894 Self {
895 max_wasm_file_size: 10 * 1024 * 1024, allowed_imports,
897 forbidden_imports,
898 max_memory_pages: 256, max_functions: 1000,
900 allow_floats: true,
901 allow_simd: false,
902 allow_network_access: false,
903 allow_filesystem_read: false,
904 allow_filesystem_write: false,
905 }
906 }
907}
908
909impl SecurityPolicies {
910 pub fn validate_manifest(&self, manifest: &PluginManifest) -> LoaderResult<()> {
912 let _caps = PluginCapabilities::from_strings(&manifest.capabilities);
918
919 Ok(())
923 }
924
925 pub fn validate_capabilities(&self, capabilities: &PluginCapabilities) -> LoaderResult<()> {
927 if capabilities.resources.max_memory_bytes > self.max_memory_bytes() {
929 return Err(PluginLoaderError::security(format!(
930 "Memory limit {} exceeds maximum allowed {}",
931 capabilities.resources.max_memory_bytes,
932 self.max_memory_bytes()
933 )));
934 }
935
936 if capabilities.resources.max_cpu_percent > self.max_cpu_percent() {
937 return Err(PluginLoaderError::security(format!(
938 "CPU limit {:.2}% exceeds maximum allowed {:.2}%",
939 capabilities.resources.max_cpu_percent,
940 self.max_cpu_percent()
941 )));
942 }
943
944 Ok(())
945 }
946
947 pub fn validate_wasm_binary(&self, bytes: &[u8]) -> LoaderResult<()> {
949 let module = wasmtime::Module::new(&wasmtime::Engine::default(), bytes)
950 .map_err(|e| PluginLoaderError::wasm(format!("Invalid WASM module: {e}")))?;
951 self.validate_wasm_module(&module)?;
952 self.validate_data_segments(bytes)
953 }
954
955 pub fn validate_wasm_module(&self, module: &wasmtime::Module) -> LoaderResult<()> {
959 self.validate_imports(module)?;
963
964 self.validate_exports(module)?;
966
967 self.validate_memory_usage(module)?;
969
970 self.check_dangerous_operations(module)?;
972
973 self.validate_function_limits(module)?;
975
976 Ok(())
977 }
978
979 fn validate_imports(&self, module: &wasmtime::Module) -> LoaderResult<()> {
981 let _module_info = module.resources_required();
983
984 for import in module.imports() {
986 let module_name = import.module();
987 let field_name = import.name();
988
989 let allowed_modules = [
991 "wasi_snapshot_preview1",
992 "wasi:io/streams",
993 "wasi:filesystem/types",
994 "mockforge:plugin/host",
995 ];
996
997 if !allowed_modules.contains(&module_name) {
998 return Err(PluginLoaderError::SecurityViolation {
999 violation: format!("Disallowed import module: {}", module_name),
1000 });
1001 }
1002
1003 match module_name {
1005 "wasi_snapshot_preview1" => {
1006 self.validate_wasi_import(field_name)?;
1007 }
1008 "mockforge:plugin/host" => {
1009 self.validate_host_import(field_name)?;
1010 }
1011 _ => {
1012 }
1014 }
1015 }
1016
1017 Ok(())
1018 }
1019
1020 fn validate_wasi_import(&self, field_name: &str) -> LoaderResult<()> {
1022 let allowed_functions = [
1024 "fd_read",
1026 "fd_write",
1027 "fd_close",
1028 "fd_fdstat_get",
1029 "path_open",
1031 "path_readlink",
1032 "path_filestat_get",
1033 "clock_time_get",
1035 "proc_exit",
1037 "random_get",
1039 ];
1040
1041 if !allowed_functions.contains(&field_name) {
1042 return Err(PluginLoaderError::SecurityViolation {
1043 violation: format!("Disallowed WASI function: {}", field_name),
1044 });
1045 }
1046
1047 Ok(())
1048 }
1049
1050 fn validate_host_import(&self, field_name: &str) -> LoaderResult<()> {
1052 let allowed_functions = [
1054 "log_message",
1055 "get_config_value",
1056 "store_data",
1057 "retrieve_data",
1058 ];
1059
1060 if !allowed_functions.contains(&field_name) {
1061 return Err(PluginLoaderError::SecurityViolation {
1062 violation: format!("Disallowed host function: {}", field_name),
1063 });
1064 }
1065
1066 Ok(())
1067 }
1068
1069 fn validate_exports(&self, module: &wasmtime::Module) -> LoaderResult<()> {
1071 let _module_info = module.resources_required();
1072
1073 let mut has_memory_export = false;
1075 let mut function_exports = 0;
1076
1077 for export in module.exports() {
1078 match export.ty() {
1079 wasmtime::ExternType::Memory(_) => {
1080 has_memory_export = true;
1081 }
1082 wasmtime::ExternType::Func(_) => {
1083 function_exports += 1;
1084
1085 }
1088 _ => {
1089 }
1091 }
1092 }
1093
1094 if !has_memory_export {
1096 return Err(PluginLoaderError::ValidationError {
1097 message: "WASM module must export memory".to_string(),
1098 });
1099 }
1100
1101 if function_exports > 1000 {
1103 return Err(PluginLoaderError::SecurityViolation {
1104 violation: format!("Too many function exports: {} (max: 1000)", function_exports),
1105 });
1106 }
1107
1108 Ok(())
1109 }
1110
1111 fn validate_memory_usage(&self, module: &wasmtime::Module) -> LoaderResult<()> {
1113 let _module_info = module.resources_required();
1114
1115 for import in module.imports() {
1116 if let wasmtime::ExternType::Memory(memory_type) = import.ty() {
1117 if let Some(max) = memory_type.maximum() {
1119 if max > 100 {
1120 return Err(PluginLoaderError::SecurityViolation {
1122 violation: format!("Memory limit too high: {} pages (max: 100)", max),
1123 });
1124 }
1125 }
1126
1127 if memory_type.maximum().is_none() && memory_type.is_shared() {
1129 return Err(PluginLoaderError::SecurityViolation {
1130 violation: "Shared memory without maximum limit not allowed".to_string(),
1131 });
1132 }
1133 }
1134 }
1135
1136 for export in module.exports() {
1138 if let wasmtime::ExternType::Memory(memory_type) = export.ty() {
1139 if let Some(max) = memory_type.maximum() {
1140 if max > 100 {
1141 return Err(PluginLoaderError::SecurityViolation {
1142 violation: format!("Exported memory limit too high: {} pages", max),
1143 });
1144 }
1145 }
1146 }
1147 }
1148
1149 Ok(())
1150 }
1151
1152 fn check_dangerous_operations(&self, module: &wasmtime::Module) -> LoaderResult<()> {
1154 let _module_info = module.resources_required();
1161
1162 self.validate_function_sizes(module)?;
1164
1165 let suspicious_imports = ["env", "wasi_unstable", "wasi_experimental"];
1167 for import in module.imports() {
1168 if suspicious_imports.contains(&import.module()) {
1169 return Err(PluginLoaderError::SecurityViolation {
1170 violation: format!("Suspicious import module: {}", import.module()),
1171 });
1172 }
1173 }
1174
1175 Ok(())
1176 }
1177
1178 fn validate_function_limits(&self, module: &wasmtime::Module) -> LoaderResult<()> {
1180 let _module_info = module.resources_required();
1181
1182 let mut function_count = 0;
1183 for export in module.exports() {
1184 if let wasmtime::ExternType::Func(_) = export.ty() {
1185 function_count += 1;
1186 }
1187 }
1188
1189 for import in module.imports() {
1191 if let wasmtime::ExternType::Func(_) = import.ty() {
1192 function_count += 1;
1193 }
1194 }
1195
1196 if function_count > 10000 {
1198 return Err(PluginLoaderError::SecurityViolation {
1199 violation: format!("Too many functions: {} (max: 10000)", function_count),
1200 });
1201 }
1202
1203 Ok(())
1204 }
1205
1206 fn validate_function_sizes(&self, module: &wasmtime::Module) -> LoaderResult<()> {
1208 for export in module.exports() {
1211 if let wasmtime::ExternType::Func(func_type) = export.ty() {
1212 let param_count = func_type.params().len();
1215 let result_count = func_type.results().len();
1216
1217 if param_count > 20 || result_count > 10 {
1219 return Err(PluginLoaderError::SecurityViolation {
1220 violation: format!(
1221 "Function '{}' has suspiciously complex signature: {} params, {} results",
1222 export.name(), param_count, result_count
1223 ),
1224 });
1225 }
1226
1227 let mut complex_types = 0;
1229 for param in func_type.params() {
1230 match param {
1231 wasmtime::ValType::V128 | wasmtime::ValType::Ref(_) => {
1232 complex_types += 1;
1233 }
1234 _ => {}
1235 }
1236 }
1237
1238 if complex_types > param_count / 2 && param_count > 5 {
1239 return Err(PluginLoaderError::SecurityViolation {
1240 violation: format!(
1241 "Function '{}' has unusually complex parameter types: {} complex types out of {} params",
1242 export.name(), complex_types, param_count
1243 ),
1244 });
1245 }
1246 }
1247 }
1248
1249 let mut total_functions = 0;
1251 for export in module.exports() {
1252 if let wasmtime::ExternType::Func(_) = export.ty() {
1253 total_functions += 1;
1254 }
1255 }
1256 for import in module.imports() {
1257 if let wasmtime::ExternType::Func(_) = import.ty() {
1258 total_functions += 1;
1259 }
1260 }
1261
1262 if total_functions > 5000 {
1265 return Err(PluginLoaderError::SecurityViolation {
1266 violation: format!(
1267 "Too many functions: {} (reasonable limit: 5000)",
1268 total_functions
1269 ),
1270 });
1271 }
1272
1273 Ok(())
1278 }
1279
1280 fn validate_data_segments(&self, wasm_bytes: &[u8]) -> LoaderResult<()> {
1282 let parser = Parser::new(0);
1287 let payloads =
1288 parser.parse_all(wasm_bytes).collect::<Result<Vec<_>, _>>().map_err(|e| {
1289 PluginLoaderError::ValidationError {
1290 message: format!("Failed to parse WASM module: {}", e),
1291 }
1292 })?;
1293
1294 let suspicious_patterns = [
1296 "http://",
1297 "https://",
1298 "/bin/",
1299 "/usr/bin/",
1300 "eval(",
1301 "exec(",
1302 "system(",
1303 "shell",
1304 "cmd.exe",
1305 "powershell",
1306 "wget",
1307 "curl",
1308 "nc ",
1309 "netcat",
1310 "python -c",
1311 "ruby -e",
1312 "node -e",
1313 "bash -c",
1314 "sh -c",
1315 ];
1316
1317 for payload in payloads {
1319 if let Payload::DataSection(data_section) = payload {
1320 for data_segment_result in data_section {
1321 let data_segment =
1322 data_segment_result.map_err(|e| PluginLoaderError::ValidationError {
1323 message: format!("Failed to read data segment: {}", e),
1324 })?;
1325 let data = data_segment.data;
1326
1327 if let Ok(data_str) = std::str::from_utf8(data) {
1329 for pattern in &suspicious_patterns {
1330 if data_str.contains(pattern) {
1331 return Err(PluginLoaderError::SecurityViolation {
1332 violation: format!(
1333 "Data segment contains suspicious content: '{}'",
1334 pattern
1335 ),
1336 });
1337 }
1338 }
1339 } else {
1340 for pattern in &suspicious_patterns {
1342 if data
1343 .windows(pattern.len())
1344 .any(|window| window == pattern.as_bytes())
1345 {
1346 return Err(PluginLoaderError::SecurityViolation {
1347 violation: format!(
1348 "Data segment contains suspicious content: '{}'",
1349 pattern
1350 ),
1351 });
1352 }
1353 }
1354 }
1355 }
1356 }
1357 }
1358
1359 Ok(())
1360 }
1361
1362 pub fn allow_network_access(&self) -> bool {
1364 self.allow_network_access
1365 }
1366
1367 pub fn allow_filesystem_read(&self) -> bool {
1369 self.allow_filesystem_read
1370 }
1371
1372 pub fn allow_filesystem_write(&self) -> bool {
1374 self.allow_filesystem_write
1375 }
1376
1377 pub fn max_memory_bytes(&self) -> usize {
1379 10 * 1024 * 1024 }
1381
1382 pub fn max_cpu_percent(&self) -> f64 {
1384 0.5 }
1386}
1387
1388#[derive(Debug, Clone)]
1390pub struct ValidationSummary {
1391 pub is_valid: bool,
1393 pub manifest_valid: bool,
1395 pub wasm_valid: bool,
1397 pub manifest: Option<PluginManifest>,
1399 pub errors: Vec<String>,
1401 pub warnings: Vec<String>,
1403}
1404
1405impl Default for ValidationSummary {
1406 fn default() -> Self {
1407 Self {
1408 is_valid: true,
1409 manifest_valid: false,
1410 wasm_valid: false,
1411 manifest: None,
1412 errors: Vec::new(),
1413 warnings: Vec::new(),
1414 }
1415 }
1416}
1417
1418impl ValidationSummary {
1419 pub fn add_error<S: Into<String>>(&mut self, error: S) {
1421 self.errors.push(error.into());
1422 self.is_valid = false;
1423 }
1424
1425 pub fn add_warning<S: Into<String>>(&mut self, warning: S) {
1427 self.warnings.push(warning.into());
1428 }
1429
1430 pub fn has_errors(&self) -> bool {
1432 !self.errors.is_empty()
1433 }
1434
1435 pub fn has_warnings(&self) -> bool {
1437 !self.warnings.is_empty()
1438 }
1439
1440 pub fn error_count(&self) -> usize {
1442 self.errors.len()
1443 }
1444
1445 pub fn warning_count(&self) -> usize {
1447 self.warnings.len()
1448 }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454
1455 #[tokio::test]
1456 async fn test_security_policies_creation() {
1457 let policies = SecurityPolicies::default();
1458 assert!(!policies.allow_network_access());
1459 assert!(!policies.allow_filesystem_read());
1460 assert!(!policies.allow_filesystem_write());
1461 assert_eq!(policies.max_memory_bytes(), 10 * 1024 * 1024);
1462 assert_eq!(policies.max_cpu_percent(), 0.5);
1463 }
1464
1465 #[tokio::test]
1466 async fn test_validation_summary() {
1467 let mut summary = ValidationSummary::default();
1468 assert!(summary.is_valid);
1469 assert!(!summary.has_errors());
1470 assert!(!summary.has_warnings());
1471
1472 summary.add_error("Test error");
1473 assert!(!summary.is_valid);
1474 assert!(summary.has_errors());
1475 assert_eq!(summary.error_count(), 1);
1476
1477 summary.add_warning("Test warning");
1478 assert!(summary.has_warnings());
1479 assert_eq!(summary.warning_count(), 1);
1480 }
1481
1482 #[tokio::test]
1483 async fn test_plugin_validator_creation() {
1484 let config = PluginLoaderConfig::default();
1485 let _validator = PluginValidator::new(config);
1486 }
1489}
1490
1491#[cfg(test)]
1492mod original_binary_regressions {
1493 use super::SecurityPolicies;
1494 const WASM: &[u8] = b"\0asm\x01\0\0\0\x05\x04\x01\x01\x01\x01\x07\x0a\x01\x06memory\x02\0";
1495
1496 #[test]
1497 fn loadable_original_binary_passes_structural_and_data_checks() {
1498 SecurityPolicies::default().validate_wasm_binary(WASM).unwrap();
1499 }
1500
1501 #[test]
1502 fn suspicious_data_is_rejected_in_utf8_and_binary_segments() {
1503 for data in [b"/bin/sh".as_slice(), b"\xff/bin/sh".as_slice()] {
1504 let mut wasm = WASM.to_vec();
1505 wasm.extend_from_slice(&[
1506 11,
1507 (6 + data.len()) as u8,
1508 1,
1509 0,
1510 0x41,
1511 0,
1512 0x0b,
1513 data.len() as u8,
1514 ]);
1515 wasm.extend_from_slice(data);
1516 let error = SecurityPolicies::default().validate_wasm_binary(&wasm).unwrap_err();
1517 assert!(error.is_security_error(), "{error}");
1518 assert!(error.to_string().contains("suspicious content"), "{error}");
1519 }
1520 }
1521
1522 #[test]
1523 fn original_binary_validation_still_requires_memory_export() {
1524 let error = SecurityPolicies::default()
1525 .validate_wasm_binary(b"\0asm\x01\0\0\0")
1526 .unwrap_err();
1527 assert!(error.to_string().contains("must export memory"), "{error}");
1528 }
1529}