Skip to content
API Documentation
BastionGuard™
ArchiveSandbox.cpp
Go to the documentation of this file.
1/*
2 * BastionGuard™
3 * Copyright (C) 2025–2026 Calogero Scarnà
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, version 3.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 *
17 * BastionGuard™ is a trademark of Calogero Scarnà.
18 * The BastionGuard™ name and branding are not licensed under the GPL.
19 */
20
21#include "ArchiveSandbox.hpp"
22
23#include <algorithm>
24#include <array>
25#include <cerrno>
26#include <chrono>
27#include <cctype>
28#include <csignal>
29#include <cstring>
30#include <filesystem>
31#include <string>
32#include <thread>
33#include <vector>
34
35#include <fcntl.h>
36#include <poll.h>
37#include <sys/resource.h>
38#include <sys/stat.h>
39#include <sys/types.h>
40#include <sys/wait.h>
41#include <unistd.h>
42
43namespace fs = std::filesystem;
44
45namespace {
46
47// Legge tutto quello che è disponibile subito su fd (non-blocking).
48// Ritorna false solo su errore reale (non su EAGAIN/EWOULDBLOCK/EINTR).
49// Ritorna true anche in caso di EOF (n == 0), segnalando chiusura via got_eof.
50bool drain_fd_nonblocking(int fd, std::string& out, bool& got_eof, bool& truncated) {
51 constexpr std::size_t max_output_bytes = 1024 * 1024;
52 got_eof = false;
53 std::array<char, 4096> buf{};
54 while (true) {
55 const ssize_t n = ::read(fd, buf.data(), buf.size());
56 if (n == 0) {
57 got_eof = true;
58 return true;
59 }
60 if (n < 0) {
61 if (errno == EINTR) continue;
62 if (errno == EAGAIN || errno == EWOULDBLOCK) return true;
63 return false;
64 }
65 const auto length = static_cast<std::size_t>(n);
66 if (out.size() < max_output_bytes) {
67 const auto copy = std::min(length, max_output_bytes - out.size());
68 out.append(buf.data(), copy);
69 if (copy != length) truncated = true;
70 } else {
71 truncated = true;
72 }
73 }
74}
75
76bool set_nonblocking(int fd) {
77 const int flags = ::fcntl(fd, F_GETFL, 0);
78 if (flags < 0) return false;
79 return ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0;
80}
81
83 struct rlimit cpu_lim {};
84 cpu_lim.rlim_cur = 10;
85 cpu_lim.rlim_max = 10;
86 ::setrlimit(RLIMIT_CPU, &cpu_lim);
87
88 struct rlimit as_lim {};
89 as_lim.rlim_cur = 512ULL * 1024ULL * 1024ULL;
90 as_lim.rlim_max = 512ULL * 1024ULL * 1024ULL;
91 ::setrlimit(RLIMIT_AS, &as_lim);
92
93 struct rlimit fsize_lim {};
94 fsize_lim.rlim_cur = 128ULL * 1024ULL * 1024ULL;
95 fsize_lim.rlim_max = 128ULL * 1024ULL * 1024ULL;
96 ::setrlimit(RLIMIT_FSIZE, &fsize_lim);
97
98 struct rlimit nofile_lim {};
99 nofile_lim.rlim_cur = 64;
100 nofile_lim.rlim_max = 64;
101 ::setrlimit(RLIMIT_NOFILE, &nofile_lim);
102}
103
104bool json_has_true(const std::string& json, const std::string& key) {
105 const std::string pat = "\"" + key + "\":true";
106 return json.find(pat) != std::string::npos;
107}
108
109int json_read_int(const std::string& json, const std::string& key, int fallback = 0) {
110 const std::string pat = "\"" + key + "\":";
111 std::size_t pos = json.find(pat);
112 if (pos == std::string::npos) {
113 return fallback;
114 }
115
116 pos += pat.size();
117 while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
118 ++pos;
119 }
120
121 bool neg = false;
122 if (pos < json.size() && json[pos] == '-') {
123 neg = true;
124 ++pos;
125 }
126
127 int value = 0;
128 bool seen = false;
129 while (pos < json.size() && std::isdigit(static_cast<unsigned char>(json[pos]))) {
130 seen = true;
131 value = value * 10 + (json[pos] - '0');
132 ++pos;
133 }
134
135 if (!seen) {
136 return fallback;
137 }
138
139 return neg ? -value : value;
140}
141
142std::string json_read_string(const std::string& json, const std::string& key) {
143 const std::string pat = "\"" + key + "\":\"";
144 std::size_t pos = json.find(pat);
145 if (pos == std::string::npos) {
146 return {};
147 }
148
149 pos += pat.size();
150
151 std::string out;
152 while (pos < json.size()) {
153 char c = json[pos++];
154 if (c == '\\') {
155 if (pos < json.size()) {
156 out.push_back(json[pos++]);
157 }
158 continue;
159 }
160 if (c == '"') {
161 break;
162 }
163 out.push_back(c);
164 }
165
166 return out;
167}
168
169} // namespace
170
172 const std::string& worker_path,
173 const std::string& input_path,
174 int timeout_seconds)
175{
176 SandboxExecResult result;
177
178 if (!fs::exists(worker_path) || !fs::is_regular_file(worker_path)) {
179 result.stderr_text = "invalid worker_path";
180 return result;
181 }
182
183 if (!fs::exists(input_path) || !fs::is_regular_file(input_path)) {
184 result.stderr_text = "invalid input_path";
185 return result;
186 }
187
188 char tmp_template[] = "/tmp/bastionguard-archive-XXXXXX";
189 char* tmp_dir = ::mkdtemp(tmp_template);
190 if (!tmp_dir) {
191 result.stderr_text = "mkdtemp failed";
192 return result;
193 }
194
195 const fs::path sandbox_root(tmp_dir);
196 const fs::path work_dir = sandbox_root / "work";
197 std::error_code ec;
198 fs::create_directories(work_dir, ec);
199 if (ec) {
200 result.stderr_text = "create_directories failed";
201 fs::remove_all(sandbox_root, ec);
202 return result;
203 }
204
205 int out_pipe[2] = {-1, -1};
206 int err_pipe[2] = {-1, -1};
207
208 if (::pipe(out_pipe) != 0 || ::pipe(err_pipe) != 0) {
209 result.stderr_text = "pipe failed";
210 for (int fd : {out_pipe[0], out_pipe[1], err_pipe[0], err_pipe[1]}) {
211 if (fd >= 0) ::close(fd);
212 }
213 fs::remove_all(sandbox_root, ec);
214 return result;
215 }
216
217 const pid_t pid = ::fork();
218 if (pid < 0) {
219 result.stderr_text = "fork failed";
220 ::close(out_pipe[0]);
221 ::close(out_pipe[1]);
222 ::close(err_pipe[0]);
223 ::close(err_pipe[1]);
224 fs::remove_all(sandbox_root, ec);
225 return result;
226 }
227
228 if (pid == 0) {
229 ::close(out_pipe[0]);
230 ::close(err_pipe[0]);
231
232 ::dup2(out_pipe[1], STDOUT_FILENO);
233 ::dup2(err_pipe[1], STDERR_FILENO);
234
235 ::close(out_pipe[1]);
236 ::close(err_pipe[1]);
237
239
240 std::vector<std::string> args = {
241 "bwrap",
242 "--die-with-parent",
243 "--new-session",
244 "--unshare-ipc",
245 "--unshare-pid",
246 "--unshare-uts",
247 "--unshare-cgroup-try",
248 "--proc", "/proc",
249 "--dev", "/dev",
250 "--ro-bind", "/usr", "/usr",
251 "--ro-bind", "/bin", "/bin",
252 "--ro-bind", "/lib", "/lib",
253 "--ro-bind", "/lib64", "/lib64",
254 "--tmpfs", "/tmp",
255 "--dir", "/work",
256 "--ro-bind", input_path, "/work/input.zip",
257 "--ro-bind", worker_path, "/worker",
258 "--chdir", "/work",
259 "--setenv", "HOME", "/tmp",
260 "--setenv", "TMPDIR", "/tmp",
261 "/worker",
262 "/work/input.zip"
263 };
264
265 std::vector<char*> argv;
266 argv.reserve(args.size() + 1);
267 for (auto& s : args) {
268 argv.push_back(s.data());
269 }
270 argv.push_back(nullptr);
271
272 ::execvp("bwrap", argv.data());
273 _exit(127);
274 }
275
276 result.launched = true;
277
278 ::close(out_pipe[1]);
279 ::close(err_pipe[1]);
280
281 // Mettiamo entrambe le pipe in non-blocking così possiamo drenarle
282 // mentre il child sta ancora girando. Se non lo facessimo, un child
283 // che scrive più di PIPE_BUF si bloccherebbe su write() e non uscirebbe
284 // mai -> deadlock fino al timeout.
285 set_nonblocking(out_pipe[0]);
286 set_nonblocking(err_pipe[0]);
287
288 const auto start = std::chrono::steady_clock::now();
289 bool child_done = false;
290 int status = 0;
291
292 bool out_eof = false;
293 bool err_eof = false;
294
295 const int effective_timeout = std::max(1, timeout_seconds);
296 while (true) {
297 // Se il child è già uscito E entrambe le pipe sono in EOF,
298 // abbiamo finito.
299 if (!child_done) {
300 const pid_t w = ::waitpid(pid, &status, WNOHANG);
301 if (w == pid) {
302 child_done = true;
303 } else if (w < 0 && errno != EINTR) {
304 result.stderr_text = "waitpid failed";
305 // Do not exit leaving an untracked child behind.
306 ::kill(pid, SIGKILL);
307 ::waitpid(pid, &status, 0);
308 break;
309 }
310 }
311
312 // Timeout: SIGKILL e reap sincrono.
313 const auto now = std::chrono::steady_clock::now();
314 const auto elapsed =
315 std::chrono::duration_cast<std::chrono::seconds>(now - start).count();
316 if (elapsed >= effective_timeout && (!child_done || !out_eof || !err_eof)) {
317 // A forked grandchild may still own an output pipe after its
318 // direct parent exits. Bound the entire operation, not only waitpid.
319 result.timed_out = true;
320 if (!child_done) {
321 ::kill(pid, SIGKILL);
322 ::waitpid(pid, &status, 0);
323 child_done = true;
324 }
325 break;
326 }
327
328 // Drena le pipe disponibili (non-blocking).
329 struct pollfd fds[2];
330 int nfds = 0;
331 if (!out_eof) { fds[nfds].fd = out_pipe[0]; fds[nfds].events = POLLIN; ++nfds; }
332 if (!err_eof) { fds[nfds].fd = err_pipe[0]; fds[nfds].events = POLLIN; ++nfds; }
333
334 if (nfds > 0) {
335 // Poll breve: se il child non è ancora morto, controlliamo poi
336 // waitpid al prossimo giro; se è morto, drenaiamo finché non c'è
337 // più nulla da leggere.
338 const int poll_timeout_ms = child_done ? 100 : 50;
339 const int pr = ::poll(fds, nfds, poll_timeout_ms);
340
341 if (pr > 0) {
342 for (int i = 0; i < nfds; ++i) {
343 if (!(fds[i].revents & (POLLIN | POLLHUP | POLLERR))) continue;
344
345 bool eof_here = false;
346 if (fds[i].fd == out_pipe[0]) {
347 drain_fd_nonblocking(out_pipe[0], result.stdout_text, eof_here, result.output_truncated);
348 if (eof_here) out_eof = true;
349 } else if (fds[i].fd == err_pipe[0]) {
350 drain_fd_nonblocking(err_pipe[0], result.stderr_text, eof_here, result.output_truncated);
351 if (eof_here) err_eof = true;
352 }
353 }
354 }
355 // pr == 0 (timeout poll) o pr < 0 con EINTR: proseguiamo il loop.
356 }
357
358 // Condizione di uscita: child morto e nessun ulteriore dato in arrivo.
359 if (child_done && (out_eof || nfds == 0) && (err_eof || nfds == 0)) {
360 break;
361 }
362
363 // Sanity: se non c'è più nulla da drenare ma il child non è morto,
364 // aspettiamo un pochino per non bruciare CPU.
365 if (!child_done && nfds == 0) {
366 std::this_thread::sleep_for(std::chrono::milliseconds(50));
367 }
368 }
369
370 // Drena residui (le EOF dovrebbero essere già arrivate, ma per sicurezza).
371 if (!out_eof) { bool e; drain_fd_nonblocking(out_pipe[0], result.stdout_text, e, result.output_truncated); }
372 if (!err_eof) { bool e; drain_fd_nonblocking(err_pipe[0], result.stderr_text, e, result.output_truncated); }
373
374 ::close(out_pipe[0]);
375 ::close(err_pipe[0]);
376
377 if (child_done) {
378 result.exited = true;
379 if (WIFEXITED(status)) {
380 result.exit_code = WEXITSTATUS(status);
381 } else if (WIFSIGNALED(status)) {
382 result.exit_code = 128 + WTERMSIG(status);
383 }
384 }
385
386 fs::remove_all(sandbox_root, ec);
387 return result;
388}
389
391 const std::string& worker_path,
392 const std::string& input_path,
393 int timeout_seconds)
394{
396
397 const auto exec = run_archive_worker_bwrap(worker_path, input_path, timeout_seconds);
398
399 if (!exec.launched) {
400 r.success = false;
401 r.sandbox_failed = true;
402 r.reason_code = "ARCHIVE_SANDBOX_LAUNCH_FAILED";
403 r.detail = exec.stderr_text;
404 return r;
405 }
406
407 if (exec.output_truncated) {
408 r.success = false;
409 r.sandbox_failed = true;
410 r.reason_code = "ARCHIVE_SANDBOX_OUTPUT_LIMIT";
411 r.detail = "archive worker exceeded output limit";
412 return r;
413 }
414
415 if (exec.timed_out) {
416 r.success = false;
417 r.sandbox_timeout = true;
418 r.reason_code = "ARCHIVE_SANDBOX_TIMEOUT";
419 r.detail = "archive worker timeout";
420 r.raw_json = exec.stdout_text;
421 return r;
422 }
423
424 if (!exec.exited || exec.exit_code != 0) {
425 r.success = false;
426 r.sandbox_failed = true;
427 r.reason_code = "ARCHIVE_SANDBOX_ANALYSIS_FAILED";
428 r.detail = exec.stderr_text.empty() ? "archive worker failed" : exec.stderr_text;
429 r.raw_json = exec.stdout_text;
430 return r;
431 }
432
433 r.success = true;
434 r.raw_json = exec.stdout_text;
435 r.is_zip = json_has_true(exec.stdout_text, "is_zip");
436 r.stored_size_mismatch = json_has_true(exec.stdout_text, "stored_size_mismatch");
437 r.header_mismatch = json_has_true(exec.stdout_text, "header_mismatch");
438 r.invalid_offset = json_has_true(exec.stdout_text, "invalid_offset");
439 r.overlapping_entries = json_has_true(exec.stdout_text, "overlapping_entries");
440 r.nested_archive = json_has_true(exec.stdout_text, "nested_archive");
441 r.risk_score = json_read_int(exec.stdout_text, "risk_score", 0);
442 r.reason_code = json_read_string(exec.stdout_text, "reason_code");
443 r.detail = json_read_string(exec.stdout_text, "detail");
444
445 const std::string risk = json_read_string(exec.stdout_text, "risk");
446 if (risk == "EVASIVE") {
447 r.risk = ArchiveRisk::EVASIVE;
448 } else if (risk == "MALFORMED") {
449 r.risk = ArchiveRisk::MALFORMED;
450 } else if (risk == "SUSPICIOUS") {
452 } else {
453 r.risk = ArchiveRisk::CLEAN;
454 }
455
456 return r;
457}
ArchiveInspectionResult analyze_archive_in_sandbox(const std::string &worker_path, const std::string &input_path, int timeout_seconds)
SandboxExecResult run_archive_worker_bwrap(const std::string &worker_path, const std::string &input_path, int timeout_seconds)
nlohmann::json json
Definition Backend.cpp:2908
int json_read_int(const std::string &json, const std::string &key, int fallback=0)
std::string json_read_string(const std::string &json, const std::string &key)
bool json_has_true(const std::string &json, const std::string &key)
bool drain_fd_nonblocking(int fd, std::string &out, bool &got_eof, bool &truncated)
std::string stderr_text
std::string stdout_text