Refactor and improve documentation, add examples
This commit is contained in:
23
examples/.gitignore
vendored
Normal file
23
examples/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
venv/
|
||||
__pycache__/
|
||||
|
||||
# Build outputs
|
||||
build-output/
|
||||
dist/
|
||||
generated/
|
||||
|
||||
# Lock files (we want fresh installs for demos)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Python
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
|
||||
# Temp files
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
89
examples/01-dev-server/README.md
Normal file
89
examples/01-dev-server/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Dev Server + Redis Demo
|
||||
|
||||
This demo shows how fence controls network access: allowing specific external domains while blocking (or allowing) localhost connections.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need Redis running on localhost:6379:
|
||||
|
||||
```bash
|
||||
docker run -p 6379:6379 redis:alpine
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Demo 1: Localhost allowed, external blocked
|
||||
|
||||
This shows that requests to Redis (local service) works, but external requests are blocked.
|
||||
|
||||
```bash
|
||||
fence -p 3000 --settings fence-external-blocked.json npm start
|
||||
```
|
||||
|
||||
Test it:
|
||||
|
||||
```bash
|
||||
# Works - localhost outbound to Redis allowed
|
||||
curl http://localhost:3000/api/users
|
||||
|
||||
# Blocked - no domains whitelisted for external requests
|
||||
curl http://localhost:3000/api/external
|
||||
```
|
||||
|
||||
## Demo 2: External Allowed, Localhost Blocked
|
||||
|
||||
This shows the opposite: whitelisted external domains work, but Redis (localhost) is blocked.
|
||||
|
||||
```bash
|
||||
fence -p 3000 --settings fence-external-only.json npm start
|
||||
```
|
||||
|
||||
You will immediately notice that Redis connection is blocked on app startup:
|
||||
|
||||
```text
|
||||
[app] Redis connection failed: connect EPERM 127.0.0.1:6379 - Local (0.0.0.0:0)
|
||||
```
|
||||
|
||||
Test it:
|
||||
|
||||
```bash
|
||||
# Works - httpbin.org is in the allowlist
|
||||
curl http://localhost:3000/api/external
|
||||
|
||||
# Blocked - localhost outbound to Redis not allowed
|
||||
curl http://localhost:3000/api/users
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Config | Redis (localhost) | External (httpbin.org) |
|
||||
|--------|-------------------|------------------------|
|
||||
| `fence-external-blocked.json` | ✓ Allowed | ✗ Blocked |
|
||||
| `fence-external-only.json` | ✗ Blocked | ✓ Allowed |
|
||||
|
||||
## Key Settings
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `allowLocalBinding` | Server can listen on ports |
|
||||
| `allowLocalOutbound` | App can connect to localhost services |
|
||||
| `allowedDomains` | Whitelist of external domains |
|
||||
|
||||
## Note: Node.js Proxy Support
|
||||
|
||||
Node.js's native `http`/`https` modules don't respect proxy environment variables. This demo uses [`undici`](https://github.com/nodejs/undici) with `ProxyAgent` to route requests through fence's proxy:
|
||||
|
||||
```javascript
|
||||
import { ProxyAgent, fetch } from "undici";
|
||||
|
||||
const proxyUrl = process.env.HTTPS_PROXY;
|
||||
const response = await fetch(url, {
|
||||
dispatcher: new ProxyAgent(proxyUrl),
|
||||
});
|
||||
```
|
||||
|
||||
Without this, external HTTP requests would fail with connection errors (the sandbox blocks them) rather than going through fence's proxy.
|
||||
200
examples/01-dev-server/app.js
Normal file
200
examples/01-dev-server/app.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Demo Express app that:
|
||||
* 1. Serves an API on port 3000
|
||||
* 2. Connects to Redis on localhost:6379
|
||||
* 3. Attempts to call external APIs (blocked by fence)
|
||||
*
|
||||
* This demonstrates allowLocalOutbound - the app can reach
|
||||
* local services (Redis) but not the external internet.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import Redis from "ioredis";
|
||||
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
// Connect to Redis on localhost
|
||||
const redis = new Redis({
|
||||
host: "127.0.0.1",
|
||||
port: 6379,
|
||||
connectTimeout: 3000,
|
||||
retryStrategy: () => null, // Don't retry, fail fast for demo
|
||||
});
|
||||
|
||||
let redisConnected = false;
|
||||
|
||||
redis.on("connect", () => {
|
||||
redisConnected = true;
|
||||
console.log("[app] Connected to Redis");
|
||||
|
||||
// Seed some demo data
|
||||
redis.set(
|
||||
"user:1",
|
||||
JSON.stringify({ id: 1, name: "Alice", email: "alice@example.com" })
|
||||
);
|
||||
redis.set(
|
||||
"user:2",
|
||||
JSON.stringify({ id: 2, name: "Bob", email: "bob@example.com" })
|
||||
);
|
||||
redis.set(
|
||||
"user:3",
|
||||
JSON.stringify({ id: 3, name: "Charlie", email: "charlie@example.com" })
|
||||
);
|
||||
console.log("[app] Seeded demo data");
|
||||
});
|
||||
|
||||
redis.on("error", (err) => {
|
||||
if (!redisConnected) {
|
||||
console.log("[app] Redis connection failed:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: Make external API call using undici with proxy support
|
||||
// Node.js native https doesn't respect HTTP_PROXY, so we use undici
|
||||
async function fetchExternal(url) {
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
|
||||
const options = {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
};
|
||||
|
||||
// Use proxy if available (set by fence)
|
||||
if (proxyUrl) {
|
||||
options.dispatcher = new ProxyAgent(proxyUrl);
|
||||
}
|
||||
|
||||
const response = await undiciFetch(url, options);
|
||||
const text = await response.text();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: text.slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
// Routes
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.json({
|
||||
message: "Dev Server Demo",
|
||||
redis: redisConnected ? "connected" : "disconnected",
|
||||
endpoints: {
|
||||
"/api/users": "List all users from Redis",
|
||||
"/api/users/:id": "Get user by ID from Redis",
|
||||
"/api/health": "Health check",
|
||||
"/api/external": "Try to call external API (blocked by fence)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/users", async (req, res) => {
|
||||
if (!redisConnected) {
|
||||
return res.status(503).json({
|
||||
error: "Redis not connected",
|
||||
hint: "Start Redis: docker run -p 6379:6379 redis:alpine",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await redis.keys("user:*");
|
||||
const users = await Promise.all(
|
||||
keys.map(async (key) => JSON.parse(await redis.get(key)))
|
||||
);
|
||||
res.json({
|
||||
source: "redis",
|
||||
count: users.length,
|
||||
data: users,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/users/:id", async (req, res) => {
|
||||
if (!redisConnected) {
|
||||
return res.status(503).json({
|
||||
error: "Redis not connected",
|
||||
hint: "Start Redis: docker run -p 6379:6379 redis:alpine",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await redis.get(`user:${req.params.id}`);
|
||||
if (user) {
|
||||
res.json({ source: "redis", data: JSON.parse(user) });
|
||||
} else {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/health", async (req, res) => {
|
||||
if (!redisConnected) {
|
||||
return res.status(503).json({
|
||||
status: "unhealthy",
|
||||
redis: "disconnected",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.ping();
|
||||
res.json({
|
||||
status: "healthy",
|
||||
redis: "connected",
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: "unhealthy",
|
||||
redis: "error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/external", async (req, res) => {
|
||||
console.log("[app] Attempting external API call...");
|
||||
|
||||
try {
|
||||
const result = await fetchExternal("https://httpbin.org/get");
|
||||
// Check if we're using a proxy (indicates fence is running)
|
||||
const usingProxy = !!(process.env.HTTPS_PROXY || process.env.HTTP_PROXY);
|
||||
res.json({
|
||||
status: "success",
|
||||
message: usingProxy
|
||||
? "✓ Request allowed (httpbin.org is whitelisted)"
|
||||
: "⚠️ No proxy detected - not running in fence",
|
||||
proxy: usingProxy ? process.env.HTTPS_PROXY : null,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: "blocked",
|
||||
message: "✓ External call blocked by fence",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Startup
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ Dev Server Demo ║
|
||||
╠═══════════════════════════════════════════════════════════╣
|
||||
║ Server: http://localhost:${PORT} ║
|
||||
║ Redis: localhost:6379 ║
|
||||
╠═══════════════════════════════════════════════════════════╣
|
||||
║ Endpoints: ║
|
||||
║ GET / - API info ║
|
||||
║ GET /api/users - List users from Redis ║
|
||||
║ GET /api/users/:id - Get user by ID ║
|
||||
║ GET /api/health - Health check ║
|
||||
║ GET /api/external - Try external call (blocked) ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
9
examples/01-dev-server/fence-external-blocked.json
Normal file
9
examples/01-dev-server/fence-external-blocked.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [".", "/tmp"]
|
||||
}
|
||||
}
|
||||
10
examples/01-dev-server/fence-external-only.json
Normal file
10
examples/01-dev-server/fence-external-only.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowedDomains": ["httpbin.org"],
|
||||
"allowLocalOutbound": false
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [".", "/tmp"]
|
||||
}
|
||||
}
|
||||
15
examples/01-dev-server/package.json
Normal file
15
examples/01-dev-server/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "dev-server-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "Demo: Dev server with Redis in fence sandbox",
|
||||
"type": "module",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ioredis": "^5.3.2",
|
||||
"undici": "^6.19.2"
|
||||
}
|
||||
}
|
||||
67
examples/02-filesystem/README.md
Normal file
67
examples/02-filesystem/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Filesystem Sandbox Demo
|
||||
|
||||
This demo shows how fence controls filesystem access with `allowWrite`, `denyWrite`, and `denyRead`.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Operation | Without Fence | With Fence |
|
||||
|-----------|---------------|------------|
|
||||
| Write to `./output/` | ✓ | ✓ (in allowWrite) |
|
||||
| Write to `./` | ✓ | ✗ (not in allowWrite) |
|
||||
| Write to `.env` | ✓ | ✗ (in denyWrite) |
|
||||
| Write to `*.key` | ✓ | ✗ (in denyWrite) |
|
||||
| Read `./demo.py` | ✓ | ✓ (allowed by default) |
|
||||
| Read `/etc/shadow` | ✗ | ✗ (in denyRead) |
|
||||
| Read `/etc/passwd` | ✓ | ✗ (in denyRead) |
|
||||
|
||||
## Run the demo
|
||||
|
||||
### Without fence (all writes succeed)
|
||||
|
||||
```bash
|
||||
python demo.py
|
||||
```
|
||||
|
||||
### With fence (unauthorized operations blocked)
|
||||
|
||||
```bash
|
||||
fence --settings fence.json python demo.py
|
||||
```
|
||||
|
||||
## Fence config
|
||||
|
||||
```json
|
||||
{
|
||||
"filesystem": {
|
||||
"allowWrite": ["./output"],
|
||||
"denyWrite": [".env", "*.key"],
|
||||
"denyRead": ["/etc/shadow", "/etc/passwd"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
1. **allowWrite** - Only paths listed here are writable. Everything else is read-only.
|
||||
|
||||
2. **denyWrite** - These paths are blocked even if they'd otherwise be allowed. Useful for protecting secrets.
|
||||
|
||||
3. **denyRead** - Block reads from sensitive system files.
|
||||
|
||||
## Key settings
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `allowWrite` | `[]` (nothing) | Directories where writes are allowed |
|
||||
| `denyWrite` | `[]` | Paths to block writes (overrides allowWrite) |
|
||||
| `denyRead` | `[]` | Paths to block reads |
|
||||
|
||||
## Protected paths
|
||||
|
||||
Fence also automatically protects certain paths regardless of config:
|
||||
|
||||
- Shell configs: `.bashrc`, `.zshrc`, `.profile`
|
||||
- Git hooks: `.git/hooks/*`
|
||||
- Git config: `.gitconfig`
|
||||
|
||||
See [ARCHITECTURE.md](../../ARCHITECTURE.md) for the full list.
|
||||
147
examples/02-filesystem/demo.py
Executable file
147
examples/02-filesystem/demo.py
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Filesystem Sandbox Demo
|
||||
|
||||
This script demonstrates fence's filesystem controls:
|
||||
- allowWrite: Only specific directories are writable
|
||||
- denyWrite: Block writes to sensitive files
|
||||
- denyRead: Block reads from sensitive paths
|
||||
|
||||
Run WITHOUT fence to see all operations succeed.
|
||||
Run WITH fence to see unauthorized operations blocked.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
os.chdir(SCRIPT_DIR)
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
def log(operation, status, message):
|
||||
icon = "✓" if status == "success" else "✗"
|
||||
print(f"[{icon}] {operation}: {message}")
|
||||
results.append({"operation": operation, "status": status, "message": message})
|
||||
|
||||
|
||||
def try_write(filepath, content, description):
|
||||
"""Attempt to write to a file."""
|
||||
try:
|
||||
path = Path(filepath)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
log(description, "success", f"Wrote to {filepath}")
|
||||
return True
|
||||
except PermissionError:
|
||||
log(description, "blocked", f"Permission denied: {filepath}")
|
||||
return False
|
||||
except OSError as e:
|
||||
log(description, "blocked", f"OS error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def try_read(filepath, description):
|
||||
"""Attempt to read from a file."""
|
||||
try:
|
||||
path = Path(filepath)
|
||||
content = path.read_text()
|
||||
log(description, "success", f"Read {len(content)} bytes from {filepath}")
|
||||
return True
|
||||
except PermissionError:
|
||||
log(description, "blocked", f"Permission denied: {filepath}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
log(description, "skipped", f"File not found: {filepath}")
|
||||
return False
|
||||
except OSError as e:
|
||||
log(description, "blocked", f"OS error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""Clean up test files."""
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(SCRIPT_DIR / "output", ignore_errors=True)
|
||||
(SCRIPT_DIR / "unauthorized.txt").unlink(missing_ok=True)
|
||||
(SCRIPT_DIR / ".env").unlink(missing_ok=True)
|
||||
(SCRIPT_DIR / "secrets.key").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
print("""
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ Filesystem Sandbox Demo ║
|
||||
╠═══════════════════════════════════════════════════════════╣
|
||||
║ Tests fence's filesystem controls: ║
|
||||
║ - allowWrite: Only ./output/ is writable ║
|
||||
║ - denyWrite: .env and *.key files are protected ║
|
||||
║ - denyRead: /etc/shadow is blocked ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
cleanup()
|
||||
|
||||
print("--- WRITE TESTS ---\n")
|
||||
|
||||
# Test 1: Write to allowed directory (should succeed)
|
||||
try_write(
|
||||
"output/data.txt",
|
||||
"This file is in the allowed output directory.\n",
|
||||
"Write to ./output/ (allowed)",
|
||||
)
|
||||
|
||||
# Test 2: Write to project root (should fail with fence)
|
||||
try_write(
|
||||
"unauthorized.txt",
|
||||
"This should not be writable.\n",
|
||||
"Write to ./ (not in allowWrite)",
|
||||
)
|
||||
|
||||
# Test 3: Write to .env file (should fail - denyWrite)
|
||||
try_write(".env", "SECRET_KEY=stolen\n", "Write to .env (in denyWrite)")
|
||||
|
||||
# Test 4: Write to .key file (should fail - denyWrite pattern)
|
||||
try_write(
|
||||
"secrets.key", "-----BEGIN PRIVATE KEY-----\n", "Write to *.key (in denyWrite)"
|
||||
)
|
||||
|
||||
print("\n--- READ TESTS ---\n")
|
||||
|
||||
# Test 5: Read from allowed file (should succeed)
|
||||
try_read("demo.py", "Read ./demo.py (allowed)")
|
||||
|
||||
# Test 6: Read from /etc/shadow (should fail - denyRead)
|
||||
try_read("/etc/shadow", "Read /etc/shadow (in denyRead)")
|
||||
|
||||
# Test 7: Read from /etc/passwd (should fail if in denyRead)
|
||||
try_read("/etc/passwd", "Read /etc/passwd (in denyRead)")
|
||||
|
||||
# Summary
|
||||
print("\n--- SUMMARY ---\n")
|
||||
|
||||
blocked = sum(1 for r in results if r["status"] == "blocked")
|
||||
succeeded = sum(1 for r in results if r["status"] == "success")
|
||||
skipped = sum(1 for r in results if r["status"] == "skipped")
|
||||
|
||||
if skipped > 0:
|
||||
print(f"({skipped} test(s) skipped - file not found)")
|
||||
|
||||
if blocked > 0:
|
||||
print(f"✅ Fence blocked {blocked} unauthorized operation(s)")
|
||||
print(f"{succeeded} allowed operation(s) succeeded")
|
||||
print("\nFilesystem sandbox is working!\n")
|
||||
else:
|
||||
print("⚠️ All operations succeeded - you are likely not running in fence")
|
||||
print("Run with: fence --settings fence.json python demo.py\n")
|
||||
|
||||
cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
examples/02-filesystem/fence.json
Normal file
10
examples/02-filesystem/fence.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": []
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["./output"],
|
||||
"denyWrite": [".env", "*.key"],
|
||||
"denyRead": ["/etc/shadow", "/etc/passwd"]
|
||||
}
|
||||
}
|
||||
15
examples/README.md
Normal file
15
examples/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Fence Examples
|
||||
|
||||
Runnable examples demonstrating `fence` capabilities.
|
||||
|
||||
If you're looking for copy/paste configs and "cookbook" workflows, also see:
|
||||
|
||||
- Config templates: [`docs/templates/`](../docs/templates/)
|
||||
- Recipes for common workflows: [`docs/recipes/`](../docs/recipes/)
|
||||
|
||||
## Examples
|
||||
|
||||
| Example | What it demonstrates | How to run |
|
||||
|--------|-----------------------|------------|
|
||||
| **[01-dev-server](01-dev-server/README.md)** | Running a dev server in the sandbox, controlling **external domains** vs **localhost outbound** (Redis), and exposing an inbound port (`-p`) | `cd examples/01-dev-server && fence -p 3000 --settings fence-external-blocked.json npm start` |
|
||||
| **[02-filesystem](02-filesystem/README.md)** | Filesystem controls: `allowWrite`, `denyWrite`, `denyRead` | `cd examples/02-filesystem && fence --settings fence.json python demo.py` |
|
||||
Reference in New Issue
Block a user