Skip to content

Commit 98d00c5

Browse files
authored
Merge pull request #78 from r33drichards/claude/audit-packages-disable-external-RMSXg
Add external module import policy enforcement via OPA
2 parents 86422b7 + 9d8773e commit 98d00c5

17 files changed

Lines changed: 915 additions & 91 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Module Policy Integration Test
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- Dockerfile
7+
- docker-compose.module-policy.yml
8+
- policies/**
9+
- tests/module-policy-integration.sh
10+
- server/src/**
11+
12+
jobs:
13+
integration-test:
14+
name: Module Policy Integration
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 15
17+
18+
steps:
19+
- name: Checkout code
20+
uses: actions/checkout@v4
21+
22+
- name: Run module policy integration tests
23+
run: ./tests/module-policy-integration.sh

docker-compose.module-policy.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Docker Compose for module policy integration tests.
2+
#
3+
# Services:
4+
# opa – OPA server loaded with policies/
5+
# mcp-default – mcp-js with external modules DISABLED (default)
6+
# mcp-opa-policy – mcp-js with --allow-external-modules + --opa-module-policy
7+
8+
services:
9+
opa:
10+
image: openpolicyagent/opa:latest
11+
command: ["run", "--server", "--addr", "0.0.0.0:8181", "/policies"]
12+
ports:
13+
- "8181:8181"
14+
volumes:
15+
- ./policies:/policies:ro
16+
17+
mcp-default:
18+
build: .
19+
command:
20+
- --http-port=3000
21+
- --directory-path=/data/heaps
22+
- --session-db-path=/data/sessions
23+
tmpfs:
24+
- /data:uid=1000,gid=1000
25+
ports:
26+
- "3001:3000"
27+
28+
mcp-opa-policy:
29+
build: .
30+
command:
31+
- --http-port=3000
32+
- --directory-path=/data/heaps
33+
- --session-db-path=/data/sessions
34+
- --allow-external-modules
35+
- --opa-url=http://opa:8181
36+
- --opa-module-policy=mcp/modules
37+
tmpfs:
38+
- /data:uid=1000,gid=1000
39+
ports:
40+
- "3002:3000"
41+
depends_on:
42+
- opa

policies/modules.rego

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package mcp.modules
2+
3+
default allow = false
4+
5+
# Allow specific npm packages (via esm.sh)
6+
allow if {
7+
input.specifier_type == "npm"
8+
npm_package_allowed
9+
}
10+
11+
# Allow specific JSR packages (via esm.sh/jsr/)
12+
allow if {
13+
input.specifier_type == "jsr"
14+
jsr_package_allowed
15+
}
16+
17+
# Allow specific URL hosts
18+
allow if {
19+
input.specifier_type == "url"
20+
url_host_allowed
21+
}
22+
23+
# ── Allowed npm packages ────────────────────────────────────────────────
24+
# Add package names (as they appear on esm.sh) to this set.
25+
# The policy checks that the resolved URL host is esm.sh and the path
26+
# starts with one of these package prefixes.
27+
28+
allowed_npm_packages := {
29+
"lodash-es",
30+
"uuid",
31+
"date-fns",
32+
"zod",
33+
}
34+
35+
npm_package_allowed if {
36+
input.url_parsed.host == "esm.sh"
37+
some pkg in allowed_npm_packages
38+
startswith(input.url_parsed.path, sprintf("/%s", [pkg]))
39+
}
40+
41+
# ── Allowed JSR packages ────────────────────────────────────────────────
42+
43+
allowed_jsr_packages := {
44+
"@luca/cases",
45+
"@std/path",
46+
}
47+
48+
jsr_package_allowed if {
49+
input.url_parsed.host == "esm.sh"
50+
some pkg in allowed_jsr_packages
51+
startswith(input.url_parsed.path, sprintf("/jsr/%s", [pkg]))
52+
}
53+
54+
# ── Allowed URL hosts ───────────────────────────────────────────────────
55+
# Direct URL imports are allowed from these exact hosts.
56+
57+
allowed_url_hosts := {
58+
"esm.sh",
59+
"cdn.jsdelivr.net",
60+
"unpkg.com",
61+
}
62+
63+
url_host_allowed if {
64+
allowed_url_hosts[input.url_parsed.host]
65+
}
66+
67+
# Wildcard suffix matches for URL hosts
68+
allowed_url_suffixes := {
69+
".esm.sh",
70+
}
71+
72+
url_host_allowed if {
73+
some suffix in allowed_url_suffixes
74+
endswith(input.url_parsed.host, suffix)
75+
}

server/fuzz/fuzz_targets/fuzz_execute_stateful.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,5 @@ fuzz_target!(|input: StatefulInput| {
7272
let max_bytes = 8 * 1024 * 1024;
7373
let wasm_default = 8 * 1024 * 1024;
7474
let handle = Arc::new(Mutex::new(None));
75-
let _ = server::engine::execute_stateful(&input.code, raw_snapshot, max_bytes, handle, &modules, wasm_default, None, None);
75+
let _ = server::engine::execute_stateful(&input.code, raw_snapshot, max_bytes, handle, &modules, wasm_default, None, None, None);
7676
});

server/fuzz/fuzz_targets/fuzz_execute_stateless.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,5 @@ fuzz_target!(|input: StatelessInput| {
5959
let max_bytes = 8 * 1024 * 1024;
6060
let wasm_default = 8 * 1024 * 1024;
6161
let handle = Arc::new(Mutex::new(None));
62-
let _ = server::engine::execute_stateless(&input.code, max_bytes, handle, &modules, wasm_default, None, None);
62+
let _ = server::engine::execute_stateless(&input.code, max_bytes, handle, &modules, wasm_default, None, None, None);
6363
});

server/fuzz/fuzz_targets/fuzz_snapshot_deserialization.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ fuzz_target!(|data: &[u8]| {
3939
let max_bytes = 64 * 1024 * 1024;
4040
let handle = Arc::new(Mutex::new(None));
4141
let wasm_default = 16 * 1024 * 1024;
42-
let _ = server::engine::execute_stateful("1", raw_snapshot, max_bytes, handle, &[], wasm_default, None, None);
42+
let _ = server::engine::execute_stateful("1", raw_snapshot, max_bytes, handle, &[], wasm_default, None, None, None);
4343
});

server/fuzz/fuzz_targets/fuzz_wasm_compile.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ fuzz_target!(|data: &[u8]| {
4343
let max_bytes = 64 * 1024 * 1024;
4444
let wasm_default = 8 * 1024 * 1024;
4545
let handle = Arc::new(Mutex::new(None));
46-
let _ = server::engine::execute_stateless("1", max_bytes, handle, &modules, wasm_default, None, None);
46+
let _ = server::engine::execute_stateless("1", max_bytes, handle, &modules, wasm_default, None, None, None);
4747
});

server/src/engine/mod.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,7 @@ pub fn execute_stateless(
720720
wasm_default_max_bytes: usize,
721721
fetch_config: Option<&fetch::FetchConfig>,
722722
console_tree: Option<sled::Tree>,
723+
module_loader_config: Option<&module_loader::ModuleLoaderConfig>,
723724
) -> (Result<String, String>, bool) {
724725
let oom_flag = Arc::new(AtomicBool::new(false));
725726

@@ -738,7 +739,10 @@ pub fn execute_stateless(
738739
// When the code contains ES module syntax (import/export) we need a
739740
// module loader that can resolve npm:/jsr:/URL specifiers.
740741
let module_loader: Option<Rc<dyn deno_core::ModuleLoader>> = if use_modules {
741-
Some(Rc::new(module_loader::NetworkModuleLoader::new()))
742+
match module_loader_config {
743+
Some(config) => Some(Rc::new(module_loader::NetworkModuleLoader::with_config(config.clone()))),
744+
None => Some(Rc::new(module_loader::NetworkModuleLoader::new())),
745+
}
742746
} else {
743747
None
744748
};
@@ -825,6 +829,7 @@ pub fn execute_stateful(
825829
wasm_default_max_bytes: usize,
826830
fetch_config: Option<&fetch::FetchConfig>,
827831
console_tree: Option<sled::Tree>,
832+
module_loader_config: Option<&module_loader::ModuleLoaderConfig>,
828833
) -> (Result<(String, Vec<u8>, String), String>, bool) {
829834
let oom_flag = Arc::new(AtomicBool::new(false));
830835

@@ -859,7 +864,10 @@ pub fn execute_stateful(
859864
}
860865

861866
let module_loader: Option<Rc<dyn deno_core::ModuleLoader>> = if use_modules {
862-
Some(Rc::new(module_loader::NetworkModuleLoader::new()))
867+
match module_loader_config {
868+
Some(config) => Some(Rc::new(module_loader::NetworkModuleLoader::with_config(config.clone()))),
869+
None => Some(Rc::new(module_loader::NetworkModuleLoader::new())),
870+
}
863871
} else {
864872
None
865873
};
@@ -989,6 +997,8 @@ pub struct Engine {
989997
fetch_config: Option<Arc<fetch::FetchConfig>>,
990998
/// Execution registry for async execution tracking and console output.
991999
execution_registry: Option<Arc<ExecutionRegistry>>,
1000+
/// Module loader configuration controlling external module access and OPA auditing.
1001+
module_loader_config: Arc<module_loader::ModuleLoaderConfig>,
9921002
}
9931003

9941004
impl Engine {
@@ -1009,6 +1019,11 @@ impl Engine {
10091019
wasm_modules: Arc::new(Vec::new()),
10101020
fetch_config: None,
10111021
execution_registry: None,
1022+
module_loader_config: Arc::new(module_loader::ModuleLoaderConfig {
1023+
allow_external: false,
1024+
opa_client: None,
1025+
opa_module_policy: None,
1026+
}),
10121027
}
10131028
}
10141029

@@ -1032,6 +1047,11 @@ impl Engine {
10321047
wasm_modules: Arc::new(Vec::new()),
10331048
fetch_config: None,
10341049
execution_registry: None,
1050+
module_loader_config: Arc::new(module_loader::ModuleLoaderConfig {
1051+
allow_external: false,
1052+
opa_client: None,
1053+
opa_module_policy: None,
1054+
}),
10351055
}
10361056
}
10371057

@@ -1059,6 +1079,12 @@ impl Engine {
10591079
self
10601080
}
10611081

1082+
/// Configure module loader settings (external module access and OPA auditing).
1083+
pub fn with_module_loader_config(mut self, config: module_loader::ModuleLoaderConfig) -> Self {
1084+
self.module_loader_config = Arc::new(config);
1085+
self
1086+
}
1087+
10621088
/// Submit code for async execution. Returns an execution ID immediately.
10631089
/// V8 runs in a background task. Use `get_execution()` to poll status and
10641090
/// `get_execution_output()` to read console output.
@@ -1150,8 +1176,9 @@ impl Engine {
11501176
let wasm_default = self.wasm_default_max_bytes;
11511177
let fc = self.fetch_config.clone();
11521178
let ct = console_tree;
1179+
let mlc = self.module_loader_config.clone();
11531180
let mut join_handle = tokio::task::spawn_blocking(move || {
1154-
execute_stateless(&code, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct))
1181+
execute_stateless(&code, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct), Some(&mlc))
11551182
});
11561183

11571184
// Publish isolate handle for cancellation once it's available.
@@ -1201,11 +1228,12 @@ impl Engine {
12011228
let wasm_default = self.wasm_default_max_bytes;
12021229
let fc = self.fetch_config.clone();
12031230
let ct = console_tree;
1231+
let mlc = self.module_loader_config.clone();
12041232

12051233
let snap_mutex = self.snapshot_mutex.clone();
12061234
let mut join_handle = tokio::task::spawn_blocking(move || {
12071235
let _guard = snap_mutex.blocking_lock();
1208-
execute_stateful(&code, raw_snapshot, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct))
1236+
execute_stateful(&code, raw_snapshot, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct), Some(&mlc))
12091237
});
12101238

12111239
// Publish isolate handle for cancellation.

0 commit comments

Comments
 (0)