Skip to content

Commit 86ef1bd

Browse files
committed
Add sandbox controller MVP and wire dynamic scan UI
1 parent bf83996 commit 86ef1bd

6 files changed

Lines changed: 333 additions & 0 deletions

File tree

app.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
scan_deb_package,
2525
)
2626

27+
from provity.sandbox_client import get_sandbox_controller_url, run_dynamic_scan
28+
2729
try:
2830
from provity.attestation import (
2931
AttestationError,
@@ -429,6 +431,8 @@ def _render_verify_attestation_tab() -> None:
429431
# 2. Virus Scan & Static Analysis
430432
with col2:
431433
st.subheader("2️⃣ Security Threat Detection (Local)")
434+
435+
st.markdown("**2-1) Static Threat Scan (ClamAV)**")
432436

433437
# ClamAV Scan
434438
if is_deb and deb_scan is not None:
@@ -506,6 +510,44 @@ def _render_verify_attestation_tab() -> None:
506510
st.caption("Raw clamscan output:")
507511
st.code(str(scan_log or ""), language="text")
508512

513+
st.divider()
514+
st.markdown("**2-2) Dynamic Threat Scan (Sandbox/VM)**")
515+
st.caption(
516+
"Planned: execute the sample in an isolated Windows VM and summarize runtime behavior (process/file/registry/network)."
517+
)
518+
controller_url = get_sandbox_controller_url()
519+
st.write(f"**Controller:** {controller_url or 'Not configured (set PROVITY_SANDBOX_CONTROLLER_URL)'}")
520+
521+
dyn_timeout = st.number_input(
522+
"Dynamic scan runtime (seconds)",
523+
min_value=5,
524+
max_value=300,
525+
value=20,
526+
step=5,
527+
key="dyn_timeout_sec",
528+
)
529+
530+
if st.button("Run dynamic scan", key="dyn_run"):
531+
with st.spinner("Running dynamic scan (sandbox/VM)..."):
532+
dyn = run_dynamic_scan(
533+
file_bytes=uploaded_file.getvalue(),
534+
filename=uploaded_file.name,
535+
file_sha256=file_hash,
536+
timeout_sec=int(dyn_timeout),
537+
controller_url=controller_url,
538+
)
539+
540+
if dyn.get("ok") is True:
541+
st.success("✅ Dynamic scan completed")
542+
st.write(f"**Run ID:** {dyn.get('run_id') or 'N/A'}")
543+
if dyn.get("notes"):
544+
st.caption("; ".join(str(x) for x in (dyn.get("notes") or [])[:5]))
545+
with st.expander("Dynamic scan report"):
546+
st.json(dyn)
547+
else:
548+
st.warning("⚠️ Dynamic scan unavailable")
549+
st.caption(str(dyn.get("reason") or "unknown"))
550+
509551
st.markdown("---")
510552

511553
# Static Analysis

docker-compose.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,20 @@ services:
1616
interval: 5s
1717
timeout: 5s
1818
retries: 10
19+
20+
sandbox-controller:
21+
build:
22+
context: ./sandbox_controller
23+
container_name: provity-sandbox-controller
24+
environment:
25+
# Set to 'mock' to test wiring without a Windows VM.
26+
SANDBOX_MODE: ${SANDBOX_MODE:-mock}
27+
SANDBOX_MAX_MB: ${SANDBOX_MAX_MB:-10}
28+
29+
# WinRM target (required when SANDBOX_MODE=winrm)
30+
WINRM_HOST: ${WINRM_HOST:-}
31+
WINRM_USER: ${WINRM_USER:-}
32+
WINRM_PASSWORD: ${WINRM_PASSWORD:-}
33+
WINRM_TRANSPORT: ${WINRM_TRANSPORT:-ntlm}
34+
ports:
35+
- "8000:8000"

provity/sandbox_client.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import json
5+
import os
6+
import urllib.request
7+
from typing import Any
8+
9+
10+
class SandboxError(RuntimeError):
11+
pass
12+
13+
14+
def get_sandbox_controller_url() -> str | None:
15+
url = os.getenv("PROVITY_SANDBOX_CONTROLLER_URL")
16+
if url and url.strip():
17+
return url.strip().rstrip("/")
18+
return None
19+
20+
21+
def run_dynamic_scan(
22+
*,
23+
file_bytes: bytes,
24+
filename: str,
25+
file_sha256: str,
26+
timeout_sec: int = 20,
27+
controller_url: str | None = None,
28+
) -> dict[str, Any]:
29+
url = (controller_url or get_sandbox_controller_url() or "").strip().rstrip("/")
30+
if not url:
31+
return {"ok": False, "reason": "Sandbox controller not configured (set PROVITY_SANDBOX_CONTROLLER_URL)"}
32+
33+
body = {
34+
"filename": filename,
35+
"file_sha256": file_sha256,
36+
"file_b64": base64.b64encode(file_bytes).decode("ascii"),
37+
"timeout_sec": int(timeout_sec),
38+
}
39+
40+
req = urllib.request.Request(
41+
url + "/scan",
42+
data=json.dumps(body).encode("utf-8"),
43+
headers={"Content-Type": "application/json"},
44+
method="POST",
45+
)
46+
47+
try:
48+
with urllib.request.urlopen(req, timeout=max(5, int(timeout_sec) + 10)) as resp:
49+
raw = resp.read()
50+
obj = json.loads(raw.decode("utf-8"))
51+
if not isinstance(obj, dict):
52+
raise SandboxError("Invalid controller response")
53+
return obj
54+
except Exception as e:
55+
return {"ok": False, "reason": f"Sandbox controller request failed: {e}"}

sandbox_controller/Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt /app/requirements.txt
6+
RUN pip install --no-cache-dir -r /app/requirements.txt
7+
8+
COPY app.py /app/app.py
9+
10+
ENV HOST=0.0.0.0
11+
ENV PORT=8000
12+
13+
EXPOSE 8000
14+
15+
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

sandbox_controller/app.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import os
5+
import time
6+
import uuid
7+
from typing import Any
8+
9+
from fastapi import FastAPI
10+
from pydantic import BaseModel, Field
11+
12+
try:
13+
import winrm # pywinrm
14+
except Exception: # pragma: no cover
15+
winrm = None # type: ignore
16+
17+
app = FastAPI(title="provity-sandbox-controller", version="0.1.0")
18+
19+
20+
class ScanRequest(BaseModel):
21+
filename: str = Field(..., min_length=1, max_length=260)
22+
file_sha256: str = Field(..., min_length=64, max_length=64)
23+
file_b64: str = Field(..., min_length=1)
24+
timeout_sec: int = Field(20, ge=5, le=300)
25+
26+
27+
def _env(name: str, default: str | None = None) -> str | None:
28+
v = os.getenv(name)
29+
if v is None or not str(v).strip():
30+
return default
31+
return v
32+
33+
34+
@app.get("/health")
35+
def health() -> dict[str, Any]:
36+
return {"ok": True, "service": "sandbox-controller"}
37+
38+
39+
def _max_bytes() -> int:
40+
mb = int(_env("SANDBOX_MAX_MB", "10") or "10")
41+
return max(1, mb) * 1024 * 1024
42+
43+
44+
def _ps_escape_single_quotes(s: str) -> str:
45+
# For single-quoted PowerShell strings: ' -> ''
46+
return s.replace("'", "''")
47+
48+
49+
def _build_powershell_script(*, b64: str, filename: str, timeout_sec: int) -> str:
50+
# Minimal dynamic run + basic observability.
51+
# Returns JSON via ConvertTo-Json.
52+
safe_name = os.path.basename(filename)
53+
54+
b64_escaped = _ps_escape_single_quotes(b64)
55+
name_escaped = _ps_escape_single_quotes(safe_name)
56+
57+
return f"""
58+
$ErrorActionPreference = 'Stop'
59+
60+
$runId = [guid]::NewGuid().ToString()
61+
$baseDir = 'C:\\provity-sandbox'
62+
$runDir = Join-Path $baseDir ('run\\' + $runId)
63+
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
64+
65+
$samplePath = Join-Path $runDir '{name_escaped}'
66+
67+
# Write file from base64
68+
$bytes = [System.Convert]::FromBase64String('{b64_escaped}')
69+
[System.IO.File]::WriteAllBytes($samplePath, $bytes)
70+
71+
function Get-ProcSnapshot {{
72+
try {{
73+
Get-CimInstance Win32_Process | Select-Object ProcessId, Name, CommandLine, CreationDate
74+
}} catch {{
75+
@()
76+
}}
77+
}}
78+
79+
function Get-NetSnapshot {{
80+
try {{
81+
Get-NetTCPConnection | Select-Object LocalAddress, LocalPort, RemoteAddress, RemotePort, State, OwningProcess
82+
}} catch {{
83+
@()
84+
}}
85+
}}
86+
87+
$beforeProc = Get-ProcSnapshot
88+
$beforeNet = Get-NetSnapshot
89+
90+
$start = Get-Date
91+
$proc = $null
92+
$exitCode = $null
93+
$note = @()
94+
95+
try {{
96+
# Attempt to start the sample. Many installers need UI; this is best-effort.
97+
$proc = Start-Process -FilePath $samplePath -PassThru
98+
Start-Sleep -Seconds {timeout_sec}
99+
100+
if ($proc -and -not $proc.HasExited) {{
101+
try {{
102+
Stop-Process -Id $proc.Id -Force
103+
$note += 'Process terminated after timeout'
104+
}} catch {{
105+
$note += ('Failed to terminate process: ' + $_.Exception.Message)
106+
}}
107+
}}
108+
109+
if ($proc) {{
110+
try {{ $exitCode = $proc.ExitCode }} catch {{ $exitCode = $null }}
111+
}}
112+
}} catch {{
113+
$note += ('Execution error: ' + $_.Exception.Message)
114+
}}
115+
116+
$afterProc = Get-ProcSnapshot
117+
$afterNet = Get-NetSnapshot
118+
119+
$elapsed = (Get-Date) - $start
120+
121+
# Diff processes by (ProcessId) presence
122+
$beforeIds = @{{}}
123+
foreach ($p in $beforeProc) {{ $beforeIds[[string]$p.ProcessId] = $true }}
124+
$newProc = @()
125+
foreach ($p in $afterProc) {{
126+
if (-not $beforeIds.ContainsKey([string]$p.ProcessId)) {{ $newProc += $p }}
127+
}}
128+
129+
$result = [ordered]@{{
130+
ok = $true
131+
run_id = $runId
132+
sample_path = $samplePath
133+
timeout_sec = {timeout_sec}
134+
exit_code = $exitCode
135+
elapsed_sec = [int][Math]::Round($elapsed.TotalSeconds)
136+
new_processes = $newProc
137+
net_connections = $afterNet
138+
notes = $note
139+
}}
140+
141+
$result | ConvertTo-Json -Depth 6
142+
""".strip()
143+
144+
145+
def _run_winrm(*, script: str) -> str:
146+
host = _env("WINRM_HOST")
147+
user = _env("WINRM_USER")
148+
password = _env("WINRM_PASSWORD")
149+
transport = _env("WINRM_TRANSPORT", "ntlm")
150+
151+
if not host or not user or not password:
152+
raise RuntimeError("WINRM is not configured (set WINRM_HOST/WINRM_USER/WINRM_PASSWORD)")
153+
if winrm is None:
154+
raise RuntimeError("pywinrm not installed")
155+
156+
# NOTE: For MVP we keep this simple. In production, pin TLS, use HTTPS, and avoid plaintext creds.
157+
session = winrm.Session(host, auth=(user, password), transport=transport)
158+
r = session.run_ps(script)
159+
stdout = (r.std_out or b"").decode("utf-8", errors="replace")
160+
stderr = (r.std_err or b"").decode("utf-8", errors="replace")
161+
if r.status_code != 0:
162+
raise RuntimeError(f"WinRM status={r.status_code}. stderr={stderr[:400]}")
163+
return stdout.strip() or stderr.strip()
164+
165+
166+
@app.post("/scan")
167+
def scan(req: ScanRequest) -> dict[str, Any]:
168+
# Allow a mock mode for wiring/testing without a VM.
169+
mode = (_env("SANDBOX_MODE", "winrm") or "winrm").lower()
170+
171+
raw = base64.b64decode(req.file_b64.encode("ascii"), validate=False)
172+
if len(raw) > _max_bytes():
173+
return {"ok": False, "reason": "file too large", "max_bytes": _max_bytes()}
174+
175+
if mode == "mock":
176+
return {
177+
"ok": True,
178+
"run_id": str(uuid.uuid4()),
179+
"reason": "mock",
180+
"elapsed_sec": 1,
181+
"new_processes": [],
182+
"net_connections": [],
183+
"notes": ["SANDBOX_MODE=mock"],
184+
}
185+
186+
start = time.time()
187+
try:
188+
ps = _build_powershell_script(b64=req.file_b64, filename=req.filename, timeout_sec=req.timeout_sec)
189+
out = _run_winrm(script=ps)
190+
# PowerShell outputs JSON; return it as parsed dict if possible.
191+
import json
192+
193+
data = json.loads(out)
194+
if isinstance(data, dict):
195+
data.setdefault("ok", True)
196+
data.setdefault("elapsed_sec", int(time.time() - start))
197+
return data
198+
return {"ok": True, "raw": data, "elapsed_sec": int(time.time() - start)}
199+
except Exception as e:
200+
return {"ok": False, "reason": str(e), "elapsed_sec": int(time.time() - start)}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fastapi>=0.110
2+
uvicorn[standard]>=0.27
3+
pywinrm>=0.4.3
4+
pydantic>=2.6

0 commit comments

Comments
 (0)