skill-sentry-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""Docker sandbox lifecycle: limits, masquerade, canary seeding, teardown.
|
|
2
|
+
|
|
3
|
+
This module is deliberately usable without a Docker daemon in unit tests: pass
|
|
4
|
+
a fake client to :class:`SandboxRunner`. The real client is created lazily, so
|
|
5
|
+
importing the server does not require Docker.
|
|
6
|
+
|
|
7
|
+
Security invariants:
|
|
8
|
+
* host mounts are the skill directory (read-only) and ``/dev/null`` for
|
|
9
|
+
``/.dockerenv``; fake cgroup/mountinfo views are served by the shim rather
|
|
10
|
+
than by Docker 29-incompatible bind mounts inside ``/proc``;
|
|
11
|
+
* the network is an ``internal`` bridge (no NAT);
|
|
12
|
+
* the container is read-only, non-root, cap-drop ALL, no-new-privileges;
|
|
13
|
+
* teardown runs in ``finally`` even on timeout or exceptions.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import contextlib
|
|
20
|
+
import ipaddress
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from contextlib import suppress
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
from uuid import UUID
|
|
30
|
+
|
|
31
|
+
from skill_sentry_cli.core.contracts import (
|
|
32
|
+
Finding,
|
|
33
|
+
FindingEvidence,
|
|
34
|
+
NetLog,
|
|
35
|
+
SandboxTrace,
|
|
36
|
+
Severity,
|
|
37
|
+
Stage,
|
|
38
|
+
StageResult,
|
|
39
|
+
utc_now,
|
|
40
|
+
)
|
|
41
|
+
from skill_sentry_cli.core.poisoned_well.canary_gen import (
|
|
42
|
+
CANARY_ROOT,
|
|
43
|
+
CanarySet,
|
|
44
|
+
generate_canary_set,
|
|
45
|
+
)
|
|
46
|
+
from skill_sentry_cli.core.sandbox.canary_fs import build_canary_tar
|
|
47
|
+
from skill_sentry_cli.core.sandbox.context_honeypot import runtime_environment
|
|
48
|
+
from skill_sentry_cli.core.sandbox.masquerade import (
|
|
49
|
+
FAKE_CGROUP_CONTENT,
|
|
50
|
+
FAKE_MOUNTINFO_CONTENT,
|
|
51
|
+
MasqueradeCheck,
|
|
52
|
+
MasqueradeConfig,
|
|
53
|
+
MasqueradeError,
|
|
54
|
+
require_masquerade,
|
|
55
|
+
run_script_text,
|
|
56
|
+
verify_container,
|
|
57
|
+
)
|
|
58
|
+
from skill_sentry_cli.core.sandbox.monitor import (
|
|
59
|
+
MonitorEvent,
|
|
60
|
+
normalise_paths,
|
|
61
|
+
parse_monitor_log,
|
|
62
|
+
synthetic_event,
|
|
63
|
+
)
|
|
64
|
+
from skill_sentry_cli.core.sandbox.sidecar import SinkholeObservation, SinkholeSidecar
|
|
65
|
+
|
|
66
|
+
LABEL_KEY = "skillsentry"
|
|
67
|
+
LABEL_VALUE = "true"
|
|
68
|
+
CANARY_HOME = CANARY_ROOT
|
|
69
|
+
MAX_CAPTURED_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
70
|
+
_SETUP_WRITER = (
|
|
71
|
+
"import base64, os, sys; "
|
|
72
|
+
"open(sys.argv[1], 'wb').write(base64.b64decode(sys.argv[2])); "
|
|
73
|
+
"os.chmod(sys.argv[1], int(sys.argv[3], 8))"
|
|
74
|
+
)
|
|
75
|
+
# The network name/subnet and sidecar IP are process-wide fixed resources.
|
|
76
|
+
# Lock across runner instances too; the per-instance lock alone would let two
|
|
77
|
+
# API engines (for example different model backends) collide.
|
|
78
|
+
_RUN_LOCK = threading.Lock()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SandboxUnavailable(RuntimeError):
|
|
82
|
+
"""Docker is not installed, or its daemon is unreachable."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SandboxError(RuntimeError):
|
|
86
|
+
"""A sandbox operation failed; the caller must not synthesise a SAFE verdict."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class SandboxRequest:
|
|
91
|
+
scan_id: UUID
|
|
92
|
+
skill_dir: Path
|
|
93
|
+
declared_intent: str = ""
|
|
94
|
+
command: tuple[str, ...] | None = None
|
|
95
|
+
canary_marker: str | None = None
|
|
96
|
+
secondary_canary: str | None = None
|
|
97
|
+
timeout_s: float | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class SandboxConfig:
|
|
102
|
+
image: str = "skillsentry-sandbox:local"
|
|
103
|
+
user: str = "65534:65534"
|
|
104
|
+
network_name: str = "skillsentry-sinkhole"
|
|
105
|
+
home: str = CANARY_HOME
|
|
106
|
+
skill_mount: str = "/skill"
|
|
107
|
+
memory_mb: int = 512
|
|
108
|
+
cpus: float = 1.0
|
|
109
|
+
pids_limit: int = 128
|
|
110
|
+
timeout_s: float = 60.0
|
|
111
|
+
poll_interval_s: float = 0.1
|
|
112
|
+
stop_timeout_s: float = 1.0
|
|
113
|
+
# None means "no explicit resolver": the live engine supplies the
|
|
114
|
+
# per-scan sinkhole sidecar's internal address. Never default to loopback;
|
|
115
|
+
# a container's loopback is not a sinkhole and would hide egress.
|
|
116
|
+
dns_server: str | None = None
|
|
117
|
+
sidecar_subnet: str = "172.30.77.0/24"
|
|
118
|
+
sidecar_ip: str = "172.30.77.2"
|
|
119
|
+
sidecar_image: str = "python:3.11-slim"
|
|
120
|
+
verify_masquerade: bool = True
|
|
121
|
+
cleanup_stale: bool = True
|
|
122
|
+
cleanup_network: bool = True
|
|
123
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
124
|
+
masquerade: MasqueradeConfig = field(default_factory=MasqueradeConfig)
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
if not self.image.strip():
|
|
128
|
+
raise ValueError("sandbox image must not be empty")
|
|
129
|
+
if not (128 <= self.memory_mb <= 4096):
|
|
130
|
+
raise ValueError("memory_mb must be between 128 and 4096")
|
|
131
|
+
if self.cpus <= 0:
|
|
132
|
+
raise ValueError("cpus must be positive")
|
|
133
|
+
if self.pids_limit < 16:
|
|
134
|
+
raise ValueError("pids_limit must be at least 16")
|
|
135
|
+
if self.timeout_s <= 0:
|
|
136
|
+
raise ValueError("timeout_s must be positive")
|
|
137
|
+
if self.dns_server:
|
|
138
|
+
try:
|
|
139
|
+
if ipaddress.ip_address(self.dns_server).is_loopback:
|
|
140
|
+
raise ValueError("sandbox DNS server must not be loopback")
|
|
141
|
+
except ValueError:
|
|
142
|
+
# Hostnames are not valid Docker --dns targets in this design.
|
|
143
|
+
raise
|
|
144
|
+
try:
|
|
145
|
+
network = ipaddress.ip_network(self.sidecar_subnet, strict=False)
|
|
146
|
+
if ipaddress.ip_address(self.sidecar_ip) not in network:
|
|
147
|
+
raise ValueError("sidecar_ip must belong to sidecar_subnet")
|
|
148
|
+
except ValueError:
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def all_labels(self) -> dict[str, str]:
|
|
153
|
+
labels = {LABEL_KEY: LABEL_VALUE}
|
|
154
|
+
labels.update(self.labels)
|
|
155
|
+
return labels
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class SandboxResult:
|
|
160
|
+
scan_id: UUID
|
|
161
|
+
container_id: str
|
|
162
|
+
canaries: CanarySet
|
|
163
|
+
exit_code: int | None
|
|
164
|
+
timed_out: bool
|
|
165
|
+
duration_ms: int
|
|
166
|
+
stdout: str
|
|
167
|
+
stderr: str
|
|
168
|
+
started_at: datetime = field(default_factory=utc_now)
|
|
169
|
+
events: list[MonitorEvent] = field(default_factory=list)
|
|
170
|
+
net_logs: list[NetLog] = field(default_factory=list)
|
|
171
|
+
sinkhole_observations: list[SinkholeObservation] = field(default_factory=list)
|
|
172
|
+
resource_usage: dict[str, Any] | None = None
|
|
173
|
+
masquerade: MasqueradeCheck | None = None
|
|
174
|
+
cleanup_ok: bool = True
|
|
175
|
+
error: str | None = None
|
|
176
|
+
timeout_s: float | None = None
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def canary_reads(self) -> list[MonitorEvent]:
|
|
180
|
+
return [event for event in self.events if event.event_type == "canary_read"]
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def probes(self) -> list[MonitorEvent]:
|
|
184
|
+
return [event for event in self.events if event.event_type == "probe_detected"]
|
|
185
|
+
|
|
186
|
+
def add_net_log(self, log: NetLog) -> None:
|
|
187
|
+
self.net_logs.append(log)
|
|
188
|
+
|
|
189
|
+
def to_trace(self) -> SandboxTrace:
|
|
190
|
+
return SandboxTrace(
|
|
191
|
+
events=[event.to_contract() for event in self.events],
|
|
192
|
+
net_logs=self.net_logs,
|
|
193
|
+
resource_usage=self.resource_usage,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def sandbox_signal(self) -> float:
|
|
197
|
+
if self.probes:
|
|
198
|
+
return 100.0
|
|
199
|
+
if self.canary_reads:
|
|
200
|
+
return 75.0
|
|
201
|
+
if self.timed_out:
|
|
202
|
+
return 40.0
|
|
203
|
+
if self.exit_code not in (0, None):
|
|
204
|
+
return 20.0
|
|
205
|
+
return 0.0
|
|
206
|
+
|
|
207
|
+
def findings(self) -> list[Finding]:
|
|
208
|
+
findings: list[Finding] = []
|
|
209
|
+
seen_paths: set[str] = set()
|
|
210
|
+
for event in self.canary_reads:
|
|
211
|
+
path = event.path_or_channel or "unknown canary"
|
|
212
|
+
if path in seen_paths:
|
|
213
|
+
continue
|
|
214
|
+
seen_paths.add(path)
|
|
215
|
+
findings.append(
|
|
216
|
+
Finding(
|
|
217
|
+
stage=Stage.SANDBOX,
|
|
218
|
+
severity=Severity.HIGH,
|
|
219
|
+
message=f"Sandboxed skill read a canary credential: {path}.",
|
|
220
|
+
finding_type="canary-read",
|
|
221
|
+
evidence=FindingEvidence(
|
|
222
|
+
path=path,
|
|
223
|
+
process=event.process,
|
|
224
|
+
marker=event.marker,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
if self.timed_out:
|
|
229
|
+
findings.append(
|
|
230
|
+
Finding(
|
|
231
|
+
stage=Stage.SANDBOX,
|
|
232
|
+
severity=Severity.MEDIUM,
|
|
233
|
+
message=(
|
|
234
|
+
f"Sandbox execution exceeded its {self.timeout_s:g} s limit and was "
|
|
235
|
+
f"killed after {self.duration_ms} ms (including container startup)."
|
|
236
|
+
if self.timeout_s is not None
|
|
237
|
+
else f"Sandbox execution exceeded its limit and was killed after "
|
|
238
|
+
f"{self.duration_ms} ms."
|
|
239
|
+
),
|
|
240
|
+
finding_type="sandbox-timeout",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
if self.exit_code not in (0, None) and not self.timed_out:
|
|
244
|
+
findings.append(
|
|
245
|
+
Finding(
|
|
246
|
+
stage=Stage.SANDBOX,
|
|
247
|
+
severity=Severity.LOW,
|
|
248
|
+
message=f"Skill exited with status {self.exit_code} inside the sandbox.",
|
|
249
|
+
finding_type="sandbox-exit",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if not self.cleanup_ok:
|
|
253
|
+
findings.append(
|
|
254
|
+
Finding(
|
|
255
|
+
stage=Stage.SANDBOX,
|
|
256
|
+
severity=Severity.NOTE,
|
|
257
|
+
message="Sandbox teardown reported an error; inspect the engine log.",
|
|
258
|
+
finding_type="sandbox-cleanup",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
return findings
|
|
262
|
+
|
|
263
|
+
def to_stage_result(self) -> StageResult:
|
|
264
|
+
trace = self.to_trace()
|
|
265
|
+
return StageResult(
|
|
266
|
+
stage=Stage.SANDBOX,
|
|
267
|
+
findings=self.findings(),
|
|
268
|
+
signals={"sandbox": self.sandbox_signal()},
|
|
269
|
+
telemetry={
|
|
270
|
+
"sandboxTrace": trace.model_dump(by_alias=True, mode="json"),
|
|
271
|
+
"startedAt": self.started_at.isoformat(),
|
|
272
|
+
"exitCode": self.exit_code,
|
|
273
|
+
"timedOut": self.timed_out,
|
|
274
|
+
"cleanupOk": self.cleanup_ok,
|
|
275
|
+
"masquerade": self.masquerade.to_dict() if self.masquerade else None,
|
|
276
|
+
},
|
|
277
|
+
degraded=self.timed_out or not self.cleanup_ok,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class SandboxRunner:
|
|
282
|
+
"""Synchronous Docker lifecycle wrapper.
|
|
283
|
+
|
|
284
|
+
The orchestrator should call :meth:`run` in a worker thread. A fake client
|
|
285
|
+
only needs ``containers``, ``networks``, ``api``, and (optionally) ``ping``.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
client: Any | None = None,
|
|
291
|
+
config: SandboxConfig | None = None,
|
|
292
|
+
*,
|
|
293
|
+
sidecar_factory: Callable[[Any], SinkholeSidecar] | None = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
self.config = config or SandboxConfig()
|
|
296
|
+
self._client = client
|
|
297
|
+
self._cleaned = False
|
|
298
|
+
self._sidecar_factory = sidecar_factory
|
|
299
|
+
self._active_dns_server: str | None = self.config.dns_server
|
|
300
|
+
# Every scan currently shares one internal network and the sidecar's
|
|
301
|
+
# fixed address. A second sidecar on that network would either fail to
|
|
302
|
+
# bind or hijack the first scan's DNS, so all runners serialise scans.
|
|
303
|
+
self._run_lock = _RUN_LOCK
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def client(self) -> Any:
|
|
307
|
+
if self._client is None:
|
|
308
|
+
try:
|
|
309
|
+
import docker
|
|
310
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
311
|
+
raise SandboxUnavailable("the docker Python SDK is not installed") from exc
|
|
312
|
+
try:
|
|
313
|
+
self._client = docker.from_env()
|
|
314
|
+
except Exception as exc: # pragma: no cover - daemon-specific
|
|
315
|
+
raise SandboxUnavailable(f"Docker is unavailable: {exc}") from exc
|
|
316
|
+
return self._client
|
|
317
|
+
|
|
318
|
+
def available(self) -> bool:
|
|
319
|
+
try:
|
|
320
|
+
ping = getattr(self.client, "ping", None)
|
|
321
|
+
if ping is None:
|
|
322
|
+
return True
|
|
323
|
+
return bool(ping())
|
|
324
|
+
except Exception:
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
def close(self) -> None:
|
|
328
|
+
"""Release the Docker client. Safe to call more than once."""
|
|
329
|
+
|
|
330
|
+
client, self._client = self._client, None
|
|
331
|
+
if client is not None:
|
|
332
|
+
with contextlib.suppress(Exception):
|
|
333
|
+
client.close()
|
|
334
|
+
|
|
335
|
+
def cleanup_stale(self) -> int:
|
|
336
|
+
"""Remove labelled containers left by a crashed previous run."""
|
|
337
|
+
|
|
338
|
+
removed = 0
|
|
339
|
+
client = self.client
|
|
340
|
+
with suppress(Exception):
|
|
341
|
+
for container in client.containers.list(
|
|
342
|
+
all=True, filters={"label": f"{LABEL_KEY}={LABEL_VALUE}"}
|
|
343
|
+
):
|
|
344
|
+
status = getattr(container, "status", "")
|
|
345
|
+
if status not in {"created", "exited", "dead"}:
|
|
346
|
+
continue
|
|
347
|
+
with suppress(Exception):
|
|
348
|
+
container.remove(force=True, v=True)
|
|
349
|
+
removed += 1
|
|
350
|
+
with suppress(Exception):
|
|
351
|
+
for network in client.networks.list(filters={"label": f"{LABEL_KEY}={LABEL_VALUE}"}):
|
|
352
|
+
with suppress(Exception):
|
|
353
|
+
network.remove()
|
|
354
|
+
self._cleaned = True
|
|
355
|
+
return removed
|
|
356
|
+
|
|
357
|
+
def run(self, request: SandboxRequest) -> SandboxResult:
|
|
358
|
+
"""Run one scan at a time on this runner.
|
|
359
|
+
|
|
360
|
+
The internal network name, subnet, and sidecar IP are intentionally
|
|
361
|
+
fixed. Serialising here prevents concurrent API scans from colliding
|
|
362
|
+
on those resources while keeping each scan's teardown isolated.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
with self._run_lock:
|
|
366
|
+
return self._run_locked(request)
|
|
367
|
+
|
|
368
|
+
def _run_locked(self, request: SandboxRequest) -> SandboxResult:
|
|
369
|
+
skill_dir = Path(request.skill_dir).resolve()
|
|
370
|
+
if not skill_dir.is_dir():
|
|
371
|
+
raise SandboxError(f"skill directory does not exist: {skill_dir}")
|
|
372
|
+
timeout_s = request.timeout_s if request.timeout_s is not None else self.config.timeout_s
|
|
373
|
+
if timeout_s <= 0:
|
|
374
|
+
raise SandboxError("sandbox timeout must be positive")
|
|
375
|
+
|
|
376
|
+
client = self.client
|
|
377
|
+
if self.config.cleanup_stale and not self._cleaned:
|
|
378
|
+
self.cleanup_stale()
|
|
379
|
+
|
|
380
|
+
canaries = generate_canary_set(
|
|
381
|
+
request.scan_id,
|
|
382
|
+
request.declared_intent,
|
|
383
|
+
marker=request.canary_marker,
|
|
384
|
+
secondary_marker=request.secondary_canary,
|
|
385
|
+
)
|
|
386
|
+
network = None
|
|
387
|
+
network_created = False
|
|
388
|
+
container = None
|
|
389
|
+
sidecar: SinkholeSidecar | None = None
|
|
390
|
+
result: SandboxResult | None = None
|
|
391
|
+
masquerade_config = self.config.masquerade
|
|
392
|
+
started_at = utc_now()
|
|
393
|
+
started = time.monotonic()
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
network, network_created = self._ensure_network(client)
|
|
397
|
+
if self._sidecar_factory is not None:
|
|
398
|
+
sidecar = self._sidecar_factory(client)
|
|
399
|
+
sidecar.start(network, request.scan_id, image=self.config.sidecar_image)
|
|
400
|
+
self._active_dns_server = sidecar.ip
|
|
401
|
+
container = self._create_container(
|
|
402
|
+
client, request, skill_dir, canaries, network, masquerade_config
|
|
403
|
+
)
|
|
404
|
+
container.start()
|
|
405
|
+
self._seed_canaries(container, canaries)
|
|
406
|
+
self._seed_setup(container)
|
|
407
|
+
if self.config.verify_masquerade:
|
|
408
|
+
check = verify_container(container, config=masquerade_config, user=self.config.user)
|
|
409
|
+
require_masquerade(check)
|
|
410
|
+
else:
|
|
411
|
+
check = None
|
|
412
|
+
self._clear_monitor_log(container)
|
|
413
|
+
exec_result, stdout, stderr = self._execute(container, request, canaries, timeout_s)
|
|
414
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
415
|
+
events = normalise_paths(
|
|
416
|
+
parse_monitor_log(
|
|
417
|
+
self._read_text(container, "/tmp/skillsentry/events.jsonl")
|
|
418
|
+
).events,
|
|
419
|
+
canaries,
|
|
420
|
+
)
|
|
421
|
+
seed_events = [
|
|
422
|
+
synthetic_event(
|
|
423
|
+
"canary_seeded",
|
|
424
|
+
path_or_channel=file.display_path,
|
|
425
|
+
marker=file.marker,
|
|
426
|
+
detail={"asset": file.asset},
|
|
427
|
+
ts=started_at,
|
|
428
|
+
)
|
|
429
|
+
for file in canaries.files
|
|
430
|
+
]
|
|
431
|
+
result = SandboxResult(
|
|
432
|
+
scan_id=request.scan_id,
|
|
433
|
+
container_id=str(getattr(container, "id", "")),
|
|
434
|
+
canaries=canaries,
|
|
435
|
+
exit_code=exec_result["exit_code"],
|
|
436
|
+
timed_out=exec_result["timed_out"],
|
|
437
|
+
duration_ms=duration_ms,
|
|
438
|
+
timeout_s=timeout_s,
|
|
439
|
+
stdout=_truncate(stdout),
|
|
440
|
+
stderr=_truncate(stderr),
|
|
441
|
+
started_at=started_at,
|
|
442
|
+
events=[*seed_events, *events],
|
|
443
|
+
resource_usage=self._resource_usage(container, duration_ms),
|
|
444
|
+
masquerade=check,
|
|
445
|
+
error=exec_result.get("error"),
|
|
446
|
+
)
|
|
447
|
+
return result
|
|
448
|
+
except MasqueradeError:
|
|
449
|
+
raise
|
|
450
|
+
except SandboxUnavailable:
|
|
451
|
+
raise
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
raise SandboxError(f"sandbox execution failed: {exc}") from exc
|
|
454
|
+
finally:
|
|
455
|
+
sidecar_observations: list[SinkholeObservation] = []
|
|
456
|
+
if sidecar is not None:
|
|
457
|
+
try:
|
|
458
|
+
sidecar_observations = sidecar.stop()
|
|
459
|
+
except Exception as exc: # teardown must never mask the verdict
|
|
460
|
+
if result is not None and result.error is None:
|
|
461
|
+
result.error = f"sinkhole sidecar teardown failed: {exc}"
|
|
462
|
+
cleanup_ok, cleanup_errors = self._teardown(container, network, network_created)
|
|
463
|
+
if result is not None:
|
|
464
|
+
result.cleanup_ok = cleanup_ok
|
|
465
|
+
if cleanup_errors and result.error is None:
|
|
466
|
+
result.error = "; ".join(cleanup_errors)
|
|
467
|
+
result.sinkhole_observations = sidecar_observations
|
|
468
|
+
result.net_logs = [_net_log(observation) for observation in sidecar_observations]
|
|
469
|
+
|
|
470
|
+
# -- Docker pieces ------------------------------------------------------
|
|
471
|
+
def _ensure_network(self, client: Any) -> tuple[Any, bool]:
|
|
472
|
+
network = None
|
|
473
|
+
try:
|
|
474
|
+
network = client.networks.get(self.config.network_name)
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
if not _is_not_found(exc):
|
|
477
|
+
raise
|
|
478
|
+
if network is not None:
|
|
479
|
+
attrs = getattr(network, "attrs", {}) or {}
|
|
480
|
+
if not attrs.get("Internal", False):
|
|
481
|
+
raise SandboxError(
|
|
482
|
+
f"existing network {self.config.network_name!r} is not internal; "
|
|
483
|
+
"refusing to run with NAT"
|
|
484
|
+
)
|
|
485
|
+
return network, False
|
|
486
|
+
kwargs: dict[str, Any] = {
|
|
487
|
+
"driver": "bridge",
|
|
488
|
+
"internal": True,
|
|
489
|
+
"attachable": False,
|
|
490
|
+
"enable_ipv6": False,
|
|
491
|
+
"labels": {**self.config.all_labels, f"{LABEL_KEY}.role": "sinkhole"},
|
|
492
|
+
}
|
|
493
|
+
if self._sidecar_factory is not None:
|
|
494
|
+
kwargs["ipam"] = {
|
|
495
|
+
"Driver": "default",
|
|
496
|
+
"Config": [{"Subnet": self.config.sidecar_subnet}],
|
|
497
|
+
}
|
|
498
|
+
network = client.networks.create(self.config.network_name, **kwargs)
|
|
499
|
+
return network, True
|
|
500
|
+
|
|
501
|
+
def _create_container(
|
|
502
|
+
self,
|
|
503
|
+
client: Any,
|
|
504
|
+
request: SandboxRequest,
|
|
505
|
+
skill_dir: Path,
|
|
506
|
+
canaries: CanarySet,
|
|
507
|
+
network: Any,
|
|
508
|
+
masquerade: MasqueradeConfig,
|
|
509
|
+
) -> Any:
|
|
510
|
+
# Keep the Docker SDK an optional runtime dependency: static-only scans
|
|
511
|
+
# must still import the engine on hosts without the Docker package.
|
|
512
|
+
from docker.types import Mount
|
|
513
|
+
|
|
514
|
+
labels = {
|
|
515
|
+
**self.config.all_labels,
|
|
516
|
+
f"{LABEL_KEY}.scan_id": str(request.scan_id),
|
|
517
|
+
f"{LABEL_KEY}.skill": skill_dir.name,
|
|
518
|
+
}
|
|
519
|
+
volumes = {
|
|
520
|
+
str(skill_dir): {"bind": self.config.skill_mount, "mode": "ro"},
|
|
521
|
+
**masquerade.volumes(),
|
|
522
|
+
}
|
|
523
|
+
# put_archive() cannot target a read-only rootfs. The canary home is
|
|
524
|
+
# an anonymous volume so it can be seeded and removed with the
|
|
525
|
+
# container (remove(v=True)); /tmp remains a bounded tmpfs and its
|
|
526
|
+
# setup files are written by _seed_setup().
|
|
527
|
+
mounts = [Mount(target=self.config.home, source=None, type="volume")]
|
|
528
|
+
tmpfs = {
|
|
529
|
+
"/tmp": "rw,nosuid,nodev,noexec,size=64m,mode=1777",
|
|
530
|
+
**masquerade.tmpfs(),
|
|
531
|
+
}
|
|
532
|
+
environment = {
|
|
533
|
+
"HOME": self.config.home,
|
|
534
|
+
"PYTHONUNBUFFERED": "1",
|
|
535
|
+
**masquerade.environment(),
|
|
536
|
+
}
|
|
537
|
+
kwargs: dict[str, Any] = {
|
|
538
|
+
"image": self.config.image,
|
|
539
|
+
"entrypoint": ["/bin/sh", "-c"],
|
|
540
|
+
"command": ["trap 'exit 0' TERM INT; while :; do sleep 3600; done"],
|
|
541
|
+
"detach": True,
|
|
542
|
+
"user": self.config.user,
|
|
543
|
+
"working_dir": self.config.skill_mount,
|
|
544
|
+
"hostname": "devbox",
|
|
545
|
+
"network": self.config.network_name,
|
|
546
|
+
"mem_limit": f"{self.config.memory_mb}m",
|
|
547
|
+
"memswap_limit": f"{self.config.memory_mb}m",
|
|
548
|
+
"nano_cpus": int(self.config.cpus * 1_000_000_000),
|
|
549
|
+
"pids_limit": self.config.pids_limit,
|
|
550
|
+
"read_only": True,
|
|
551
|
+
"security_opt": ["no-new-privileges"],
|
|
552
|
+
"cap_drop": ["ALL"],
|
|
553
|
+
"volumes": volumes,
|
|
554
|
+
"mounts": mounts,
|
|
555
|
+
"tmpfs": tmpfs,
|
|
556
|
+
"environment": environment,
|
|
557
|
+
"labels": labels,
|
|
558
|
+
"auto_remove": False,
|
|
559
|
+
"init": True,
|
|
560
|
+
"stdin_open": False,
|
|
561
|
+
"tty": False,
|
|
562
|
+
}
|
|
563
|
+
dns_server = self._active_dns_server or self.config.dns_server
|
|
564
|
+
if dns_server:
|
|
565
|
+
kwargs["dns"] = [dns_server]
|
|
566
|
+
return client.containers.create(**kwargs)
|
|
567
|
+
|
|
568
|
+
def _seed_canaries(self, container: Any, canaries: CanarySet) -> None:
|
|
569
|
+
container.put_archive(self.config.home, build_canary_tar(canaries))
|
|
570
|
+
|
|
571
|
+
def _seed_setup(self, container: Any) -> None:
|
|
572
|
+
created = container.exec_run(
|
|
573
|
+
["/bin/sh", "-c", "mkdir -p /tmp/skillsentry && chmod 0700 /tmp/skillsentry"],
|
|
574
|
+
user=self.config.user,
|
|
575
|
+
)
|
|
576
|
+
if created.exit_code not in (0, None):
|
|
577
|
+
raise SandboxError("could not create the sandbox setup directory")
|
|
578
|
+
# The setup directory lives on the /tmp tmpfs. Docker refuses
|
|
579
|
+
# put_archive() against a read-only rootfs even when the target is a
|
|
580
|
+
# tmpfs, so write the four small files through an exec as the sandbox
|
|
581
|
+
# user. This also keeps their ownership writable by the skill.
|
|
582
|
+
for name, payload, mode in _setup_entries():
|
|
583
|
+
written = container.exec_run(
|
|
584
|
+
[
|
|
585
|
+
"python3",
|
|
586
|
+
"-c",
|
|
587
|
+
_SETUP_WRITER,
|
|
588
|
+
f"/tmp/skillsentry/{name}",
|
|
589
|
+
base64.b64encode(payload).decode("ascii"),
|
|
590
|
+
format(mode, "o"),
|
|
591
|
+
],
|
|
592
|
+
user=self.config.user,
|
|
593
|
+
)
|
|
594
|
+
if written.exit_code not in (0, None):
|
|
595
|
+
raise SandboxError(f"could not write sandbox setup file: {name}")
|
|
596
|
+
|
|
597
|
+
def _clear_monitor_log(self, container: Any) -> None:
|
|
598
|
+
cleared = container.exec_run(
|
|
599
|
+
["/bin/sh", "-c", ": > /tmp/skillsentry/events.jsonl"],
|
|
600
|
+
user=self.config.user,
|
|
601
|
+
)
|
|
602
|
+
if cleared.exit_code not in (0, None):
|
|
603
|
+
raise SandboxError("could not reset the monitor log after masquerade verification")
|
|
604
|
+
|
|
605
|
+
def _execute(
|
|
606
|
+
self,
|
|
607
|
+
container: Any,
|
|
608
|
+
request: SandboxRequest,
|
|
609
|
+
canaries: CanarySet,
|
|
610
|
+
timeout_s: float,
|
|
611
|
+
) -> tuple[dict[str, Any], str, str]:
|
|
612
|
+
command = (
|
|
613
|
+
list(request.command)
|
|
614
|
+
if request.command
|
|
615
|
+
else [
|
|
616
|
+
"/bin/sh",
|
|
617
|
+
"/tmp/skillsentry/run_skill.sh",
|
|
618
|
+
]
|
|
619
|
+
)
|
|
620
|
+
environment = runtime_environment(canaries)
|
|
621
|
+
environment.update(self.config.masquerade.environment())
|
|
622
|
+
environment["PYTHONUNBUFFERED"] = "1"
|
|
623
|
+
wrapped = [
|
|
624
|
+
"/bin/sh",
|
|
625
|
+
"-c",
|
|
626
|
+
'exec "$@" > /tmp/skillsentry/stdout.log 2> /tmp/skillsentry/stderr.log',
|
|
627
|
+
"skillsentry",
|
|
628
|
+
*command,
|
|
629
|
+
]
|
|
630
|
+
client = self.client
|
|
631
|
+
exec_id = client.api.exec_create(
|
|
632
|
+
container.id,
|
|
633
|
+
wrapped,
|
|
634
|
+
stdout=False,
|
|
635
|
+
stderr=False,
|
|
636
|
+
stdin=False,
|
|
637
|
+
tty=False,
|
|
638
|
+
privileged=False,
|
|
639
|
+
user=self.config.user,
|
|
640
|
+
environment=environment,
|
|
641
|
+
workdir=self.config.skill_mount,
|
|
642
|
+
)["Id"]
|
|
643
|
+
client.api.exec_start(exec_id, detach=True)
|
|
644
|
+
|
|
645
|
+
deadline = time.monotonic() + timeout_s
|
|
646
|
+
exit_code: int | None = None
|
|
647
|
+
timed_out = False
|
|
648
|
+
while True:
|
|
649
|
+
info = client.api.exec_inspect(exec_id)
|
|
650
|
+
if not info.get("Running", False):
|
|
651
|
+
exit_code = info.get("ExitCode")
|
|
652
|
+
break
|
|
653
|
+
if time.monotonic() >= deadline:
|
|
654
|
+
timed_out = True
|
|
655
|
+
with suppress(Exception):
|
|
656
|
+
container.stop(timeout=max(1, round(self.config.stop_timeout_s)))
|
|
657
|
+
with suppress(Exception):
|
|
658
|
+
container.kill()
|
|
659
|
+
exit_code = 124
|
|
660
|
+
break
|
|
661
|
+
time.sleep(max(0.01, self.config.poll_interval_s))
|
|
662
|
+
|
|
663
|
+
stdout = self._read_text(container, "/tmp/skillsentry/stdout.log")
|
|
664
|
+
stderr = self._read_text(container, "/tmp/skillsentry/stderr.log")
|
|
665
|
+
return (
|
|
666
|
+
{
|
|
667
|
+
"exit_code": exit_code,
|
|
668
|
+
"timed_out": timed_out,
|
|
669
|
+
"error": f"sandbox timed out after {timeout_s:g}s" if timed_out else None,
|
|
670
|
+
},
|
|
671
|
+
stdout,
|
|
672
|
+
stderr,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def _read_text(self, container: Any, path: str) -> str:
|
|
676
|
+
# Never ask the container for an unbounded file. A hostile skill can
|
|
677
|
+
# write forever to stdout.log; ``head`` closes the pipe early and the
|
|
678
|
+
# host-side slice protects against a fake/compromised container.
|
|
679
|
+
with suppress(Exception):
|
|
680
|
+
result = container.exec_run(
|
|
681
|
+
["head", "-c", str(MAX_CAPTURED_OUTPUT_BYTES), path],
|
|
682
|
+
user=self.config.user,
|
|
683
|
+
)
|
|
684
|
+
if result.exit_code in (0, None) and result.output:
|
|
685
|
+
if isinstance(result.output, bytes):
|
|
686
|
+
text = result.output.decode("utf-8", errors="replace")
|
|
687
|
+
else:
|
|
688
|
+
text = str(result.output)
|
|
689
|
+
return text[:MAX_CAPTURED_OUTPUT_BYTES]
|
|
690
|
+
return ""
|
|
691
|
+
|
|
692
|
+
def _resource_usage(self, container: Any, duration_ms: int) -> dict[str, Any] | None:
|
|
693
|
+
with suppress(Exception):
|
|
694
|
+
stats = container.stats(stream=False)
|
|
695
|
+
if not isinstance(stats, dict):
|
|
696
|
+
return None
|
|
697
|
+
memory = stats.get("memory_stats", {}) or {}
|
|
698
|
+
used = int(memory.get("usage", 0) or 0)
|
|
699
|
+
peak = int(memory.get("max_usage", used) or used)
|
|
700
|
+
cpu = stats.get("cpu_stats", {}) or {}
|
|
701
|
+
previous = stats.get("precpu_stats", {}) or {}
|
|
702
|
+
cpu_total = int((cpu.get("cpu_usage") or {}).get("total_usage", 0) or 0)
|
|
703
|
+
previous_total = int((previous.get("cpu_usage") or {}).get("total_usage", 0) or 0)
|
|
704
|
+
system_total = int(cpu.get("system_cpu_usage", 0) or 0)
|
|
705
|
+
previous_system = int(previous.get("system_cpu_usage", 0) or 0)
|
|
706
|
+
online = int(cpu.get("online_cpus", 1) or 1)
|
|
707
|
+
cpu_delta = cpu_total - previous_total
|
|
708
|
+
system_delta = system_total - previous_system
|
|
709
|
+
cpu_percent = (
|
|
710
|
+
round((cpu_delta / system_delta) * online * 100.0, 1)
|
|
711
|
+
if cpu_delta > 0 and system_delta > 0
|
|
712
|
+
else 0.0
|
|
713
|
+
)
|
|
714
|
+
return {
|
|
715
|
+
"durationMs": duration_ms,
|
|
716
|
+
"peakMemoryMb": round(peak / (1024 * 1024), 1),
|
|
717
|
+
"cpuPercent": cpu_percent,
|
|
718
|
+
}
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
def _teardown(
|
|
722
|
+
self,
|
|
723
|
+
container: Any | None,
|
|
724
|
+
network: Any | None,
|
|
725
|
+
network_created: bool,
|
|
726
|
+
) -> tuple[bool, list[str]]:
|
|
727
|
+
ok = True
|
|
728
|
+
errors: list[str] = []
|
|
729
|
+
if container is not None:
|
|
730
|
+
with suppress(Exception):
|
|
731
|
+
container.stop(timeout=max(1, round(self.config.stop_timeout_s)))
|
|
732
|
+
try:
|
|
733
|
+
container.remove(force=True, v=True)
|
|
734
|
+
except Exception as exc:
|
|
735
|
+
ok = False
|
|
736
|
+
errors.append(f"container remove failed: {exc}")
|
|
737
|
+
if network is not None and network_created and self.config.cleanup_network:
|
|
738
|
+
try:
|
|
739
|
+
network.remove()
|
|
740
|
+
except Exception as exc:
|
|
741
|
+
ok = False
|
|
742
|
+
errors.append(f"network remove failed: {exc}")
|
|
743
|
+
return ok, errors
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _setup_entries() -> list[tuple[str, bytes, int]]:
|
|
747
|
+
return [
|
|
748
|
+
("run_skill.sh", run_script_text().encode("utf-8"), 0o755),
|
|
749
|
+
("cgroup", FAKE_CGROUP_CONTENT.encode("utf-8"), 0o644),
|
|
750
|
+
("mountinfo", FAKE_MOUNTINFO_CONTENT.encode("utf-8"), 0o644),
|
|
751
|
+
("events.jsonl", b"", 0o600),
|
|
752
|
+
]
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _net_log(observation: SinkholeObservation) -> NetLog:
|
|
756
|
+
return NetLog(
|
|
757
|
+
ts=observation.ts,
|
|
758
|
+
protocol=observation.protocol, # type: ignore[arg-type]
|
|
759
|
+
query_or_url=observation.query_or_url,
|
|
760
|
+
body_b64=observation.body_b64,
|
|
761
|
+
responder_action=observation.responder_action,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _truncate(value: str, limit: int = 100_000) -> str:
|
|
766
|
+
if len(value) <= limit:
|
|
767
|
+
return value
|
|
768
|
+
return f"{value[:limit]}\n... [truncated {len(value) - limit} chars]"
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _is_not_found(exc: Exception) -> bool:
|
|
772
|
+
return getattr(exc, "status_code", None) == 404 or exc.__class__.__name__ in {
|
|
773
|
+
"NotFound",
|
|
774
|
+
"NetworkNotFound",
|
|
775
|
+
"ImageNotFound",
|
|
776
|
+
}
|