This document covers the threat model, analysis pipeline, sandboxing strategy, and API authentication scheme.
Oblak assumes uploaded function code is untrusted. The goals are:
- Detect and reject malicious code before it is ever executed in production
- Contain any code that does execute within a hard isolation boundary
- Prevent executed code from reaching the host or internal network
- Prevent impersonation of users at the API level
Every function passes through six sequential security checks before it is allowed to execute. Failure at any stage causes the function to be rejected with status DETECTED or FAILED; it is never moved out of the quarantine bucket.
Tool: pip-audit
If the function archive contains a requirements.txt, pip-audit checks every declared dependency against the OSV and PyPI advisory databases for known CVEs. A single flagged dependency fails the audit.
Functions with no requirements.txt pass this stage unconditionally.
Tool: ClamAV (multi-threaded, via TCP socket)
The extracted function directory is scanned against ClamAV's malware signature database. This catches known malware samples, trojans, and exploit payloads that may be included alongside the Python code.
Tool: Semgrep with Python security rules
Semgrep performs pattern-based static analysis on the Python source. It flags:
- Dangerous built-in usage (
eval,exec,__import__, subprocess withshell=True, etc.) - Hardcoded credentials
- Insecure network operations
- Known vulnerability patterns
Each Semgrep finding is not automatically a rejection — instead, it is forwarded to the LLM judge (see below) for contextual evaluation. This prevents false positives from blocking legitimate code.
Model: Qwen 2.5 3B (via Ollama)
For each Semgrep finding, the orchestrator calls the LLM judge with:
- The Semgrep rule message (describing what was flagged)
- The sanitized source code snippet containing the finding
The model returns a verdict: MALICIOUS, SAFE, or FAILURE. If any single finding is classified MALICIOUS, the function is rejected immediately. This design reduces false positives from SAST while still catching genuinely dangerous patterns.
The code sanitizer (internal/analyzer/sanitizer/) strips comments and normalizes whitespace before sending code to the LLM, to avoid prompt injection through carefully crafted code comments.
Runtime: gVisor (runsc)
The function code is actually executed in a sandboxed Docker container using the runsc runtime. gVisor sits between the container and the host kernel, intercepting every syscall and enforcing a restricted kernel surface. It logs all intercepted calls to /tmp/gvisor-logs/.
Container configuration:
- Image:
python:3.11-alpine - Memory limit: 256 MB
- Network: enabled (for dependency import), but isolated from the host's internal network via Docker's bridge
- Entry: installs
requirements.txtif present, then callshandler.handle()
The stage collects stdout, stderr, and the gVisor syscall trace, and writes them as a structured JSON report.
The gVisor container provides a defense-in-depth layer for this analysis stage, but the primary execution isolation is the Firecracker microVM used for production invocations. These are separate environments for different purposes.
Model: Qwen 2.5 3B (via Ollama)
The JSON report from the DAST stage is sent to the LLM with a prompt asking it to classify the function's runtime behavior. The model returns:
{
"verdict": "SAFE" | "MALICIOUS",
"summary": "...",
"confidence_score": 0-100
}The judge looks for behavioral signals such as:
- Unexpected network connections
- Filesystem access outside expected paths
- Spawning child processes
- Privilege escalation attempts
- Cryptomining syscall patterns
A MALICIOUS verdict at this stage rejects the function.
Production function invocations run inside Firecracker microVMs. Firecracker is a KVM-based VMM designed for minimal attack surface; it implements only the devices necessary for running workloads (virtio-net, virtio-blk, serial console) with no legacy hardware emulation.
The jailer binary is setuid root. It performs all privileged setup (chroot, cgroups, device nodes) then drops to the firecracker user (uid/gid 900) before exec'ing Firecracker. The Go VM service does not run as root; it holds only specific Linux capabilities:
| Capability | Purpose |
|---|---|
cap_net_admin |
Configure CNI network namespaces |
cap_sys_admin |
Enter VM network namespaces |
cap_dac_override |
Hard-link images into jailer chroot |
cap_kill |
Kill VM processes on shutdown |
Each invocation gets its own set of drive images:
| Drive | Filesystem | Mount flags | Content |
|---|---|---|---|
vdb (app) |
squashfs | MS_RDONLY | MS_NOSUID | MS_NOEXEC | MS_NODEV |
User function code |
vdc (deps) |
ext4 | MS_RDONLY | MS_NOSUID | MS_NODEV |
Pre-installed dependencies |
vdd (tmp) |
ext4 | MS_NOSUID | MS_NOEXEC | MS_NODEV |
Scratch space |
- The application code is mounted read-only. User code cannot modify itself.
MS_NOSUIDprevents setuid binaries from escalating privileges.MS_NOEXECon/tmpprevents user code from writing and executing binaries.MS_NODEVprevents device file access on mounted filesystems.
The rootfs (vda) is a read-only squashfs shared via hard-link into the jailer chroot.
VMs receive an IP in 192.168.127.0/24. Host iptables rules enforce:
- Block VM → all RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) — VMs cannot reach your internal network or other VMs
- Allow VM → internet (for any outbound the function needs)
- Allow established/related return traffic
These rules are applied before any per-VM rules, using iptables first-match-wins ordering. The CNI firewall plugin (which inserts per-VM ACCEPT rules dynamically) is deliberately excluded from the CNI config to preserve rule ordering control.
The only channel between the VM and the host is vsock (port 6000). The agent inside the VM communicates using a simple JSON protocol (see Architecture — Agent). There is no SSH, no shared filesystem, no other socket. The host can send a job command and read output; it cannot be reached by the VM.
All API endpoints (except /health) require HMAC-SHA256 request signing.
The signature is computed as:
canonical_string = METHOD + "_" + PATH + "_" + SHA256(body_hex) + "_" + RFC3339_timestamp
intermediate = SHA256(canonical_string)
signature = HMAC-SHA256(key=secret_key, msg=intermediate)
The Authorization header format:
Authorization: HMAC-SHA256 Credential=<auth-id>, Signature=<hex-signature>
X-Date: <RFC3339 timestamp>
The server:
- Parses
auth-idfrom theCredentialfield - Looks up the corresponding encrypted secret key and decrypts it
- Reads the request body and computes the expected signature
- Uses
hmac.Equal(constant-time comparison) to verify - Rejects requests where
|now - X-Date| > 15 minutesto prevent replay attacks
Secret keys are encrypted with KEY_ENCRYPTION_KEY (a 64-char hex value, 256-bit AES key) before being stored in PostgreSQL. The encryption uses the authkey package. Plain-text secret keys are never stored.
The admin create-key command generates a new key pair and prints the plain-text secret exactly once. It is not recoverable.