/*
* BastionGuard™
* Copyright (C) 2025–2026 Calogero Scarnà
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* BastionGuard™ is a trademark of Calogero Scarnà.
* The BastionGuard™ name and branding are not licensed under the GPL.
*/
#include "ArchiveSandbox.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
namespace fs = std::filesystem;
namespace {
// Legge tutto quello che è disponibile subito su fd (non-blocking).
// Ritorna false solo su errore reale (non su EAGAIN/EWOULDBLOCK/EINTR).
// Ritorna true anche in caso di EOF (n == 0), segnalando chiusura via got_eof.
bool drain_fd_nonblocking(int fd, std::string& out, bool& got_eof, bool& truncated) {
constexpr std::size_t max_output_bytes = 1024 * 1024;
got_eof = false;
std::array buf{};
while (true) {
const ssize_t n = ::read(fd, buf.data(), buf.size());
if (n == 0) {
got_eof = true;
return true;
}
if (n < 0) {
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) return true;
return false;
}
const auto length = static_cast(n);
if (out.size() < max_output_bytes) {
const auto copy = std::min(length, max_output_bytes - out.size());
out.append(buf.data(), copy);
if (copy != length) truncated = true;
} else {
truncated = true;
}
}
}
bool set_nonblocking(int fd) {
const int flags = ::fcntl(fd, F_GETFL, 0);
if (flags < 0) return false;
return ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0;
}
void set_worker_rlimits() {
struct rlimit cpu_lim {};
cpu_lim.rlim_cur = 10;
cpu_lim.rlim_max = 10;
::setrlimit(RLIMIT_CPU, &cpu_lim);
struct rlimit as_lim {};
as_lim.rlim_cur = 512ULL * 1024ULL * 1024ULL;
as_lim.rlim_max = 512ULL * 1024ULL * 1024ULL;
::setrlimit(RLIMIT_AS, &as_lim);
struct rlimit fsize_lim {};
fsize_lim.rlim_cur = 128ULL * 1024ULL * 1024ULL;
fsize_lim.rlim_max = 128ULL * 1024ULL * 1024ULL;
::setrlimit(RLIMIT_FSIZE, &fsize_lim);
struct rlimit nofile_lim {};
nofile_lim.rlim_cur = 64;
nofile_lim.rlim_max = 64;
::setrlimit(RLIMIT_NOFILE, &nofile_lim);
}
bool json_has_true(const std::string& json, const std::string& key) {
const std::string pat = "\"" + key + "\":true";
return json.find(pat) != std::string::npos;
}
int json_read_int(const std::string& json, const std::string& key, int fallback = 0) {
const std::string pat = "\"" + key + "\":";
std::size_t pos = json.find(pat);
if (pos == std::string::npos) {
return fallback;
}
pos += pat.size();
while (pos < json.size() && std::isspace(static_cast(json[pos]))) {
++pos;
}
bool neg = false;
if (pos < json.size() && json[pos] == '-') {
neg = true;
++pos;
}
int value = 0;
bool seen = false;
while (pos < json.size() && std::isdigit(static_cast(json[pos]))) {
seen = true;
value = value * 10 + (json[pos] - '0');
++pos;
}
if (!seen) {
return fallback;
}
return neg ? -value : value;
}
std::string json_read_string(const std::string& json, const std::string& key) {
const std::string pat = "\"" + key + "\":\"";
std::size_t pos = json.find(pat);
if (pos == std::string::npos) {
return {};
}
pos += pat.size();
std::string out;
while (pos < json.size()) {
char c = json[pos++];
if (c == '\\') {
if (pos < json.size()) {
out.push_back(json[pos++]);
}
continue;
}
if (c == '"') {
break;
}
out.push_back(c);
}
return out;
}
} // namespace
SandboxExecResult run_archive_worker_bwrap(
const std::string& worker_path,
const std::string& input_path,
int timeout_seconds)
{
SandboxExecResult result;
if (!fs::exists(worker_path) || !fs::is_regular_file(worker_path)) {
result.stderr_text = "invalid worker_path";
return result;
}
if (!fs::exists(input_path) || !fs::is_regular_file(input_path)) {
result.stderr_text = "invalid input_path";
return result;
}
char tmp_template[] = "/tmp/bastionguard-archive-XXXXXX";
char* tmp_dir = ::mkdtemp(tmp_template);
if (!tmp_dir) {
result.stderr_text = "mkdtemp failed";
return result;
}
const fs::path sandbox_root(tmp_dir);
const fs::path work_dir = sandbox_root / "work";
std::error_code ec;
fs::create_directories(work_dir, ec);
if (ec) {
result.stderr_text = "create_directories failed";
fs::remove_all(sandbox_root, ec);
return result;
}
int out_pipe[2] = {-1, -1};
int err_pipe[2] = {-1, -1};
if (::pipe(out_pipe) != 0 || ::pipe(err_pipe) != 0) {
result.stderr_text = "pipe failed";
for (int fd : {out_pipe[0], out_pipe[1], err_pipe[0], err_pipe[1]}) {
if (fd >= 0) ::close(fd);
}
fs::remove_all(sandbox_root, ec);
return result;
}
const pid_t pid = ::fork();
if (pid < 0) {
result.stderr_text = "fork failed";
::close(out_pipe[0]);
::close(out_pipe[1]);
::close(err_pipe[0]);
::close(err_pipe[1]);
fs::remove_all(sandbox_root, ec);
return result;
}
if (pid == 0) {
::close(out_pipe[0]);
::close(err_pipe[0]);
::dup2(out_pipe[1], STDOUT_FILENO);
::dup2(err_pipe[1], STDERR_FILENO);
::close(out_pipe[1]);
::close(err_pipe[1]);
set_worker_rlimits();
std::vector args = {
"bwrap",
"--die-with-parent",
"--new-session",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup-try",
"--proc", "/proc",
"--dev", "/dev",
"--ro-bind", "/usr", "/usr",
"--ro-bind", "/bin", "/bin",
"--ro-bind", "/lib", "/lib",
"--ro-bind", "/lib64", "/lib64",
"--tmpfs", "/tmp",
"--dir", "/work",
"--ro-bind", input_path, "/work/input.zip",
"--ro-bind", worker_path, "/worker",
"--chdir", "/work",
"--setenv", "HOME", "/tmp",
"--setenv", "TMPDIR", "/tmp",
"/worker",
"/work/input.zip"
};
std::vector argv;
argv.reserve(args.size() + 1);
for (auto& s : args) {
argv.push_back(s.data());
}
argv.push_back(nullptr);
::execvp("bwrap", argv.data());
_exit(127);
}
result.launched = true;
::close(out_pipe[1]);
::close(err_pipe[1]);
// Mettiamo entrambe le pipe in non-blocking così possiamo drenarle
// mentre il child sta ancora girando. Se non lo facessimo, un child
// che scrive più di PIPE_BUF si bloccherebbe su write() e non uscirebbe
// mai -> deadlock fino al timeout.
set_nonblocking(out_pipe[0]);
set_nonblocking(err_pipe[0]);
const auto start = std::chrono::steady_clock::now();
bool child_done = false;
int status = 0;
bool out_eof = false;
bool err_eof = false;
const int effective_timeout = std::max(1, timeout_seconds);
while (true) {
// Se il child è già uscito E entrambe le pipe sono in EOF,
// abbiamo finito.
if (!child_done) {
const pid_t w = ::waitpid(pid, &status, WNOHANG);
if (w == pid) {
child_done = true;
} else if (w < 0 && errno != EINTR) {
result.stderr_text = "waitpid failed";
// Do not exit leaving an untracked child behind.
::kill(pid, SIGKILL);
::waitpid(pid, &status, 0);
break;
}
}
// Timeout: SIGKILL e reap sincrono.
const auto now = std::chrono::steady_clock::now();
const auto elapsed =
std::chrono::duration_cast(now - start).count();
if (elapsed >= effective_timeout && (!child_done || !out_eof || !err_eof)) {
// A forked grandchild may still own an output pipe after its
// direct parent exits. Bound the entire operation, not only waitpid.
result.timed_out = true;
if (!child_done) {
::kill(pid, SIGKILL);
::waitpid(pid, &status, 0);
child_done = true;
}
break;
}
// Drena le pipe disponibili (non-blocking).
struct pollfd fds[2];
int nfds = 0;
if (!out_eof) { fds[nfds].fd = out_pipe[0]; fds[nfds].events = POLLIN; ++nfds; }
if (!err_eof) { fds[nfds].fd = err_pipe[0]; fds[nfds].events = POLLIN; ++nfds; }
if (nfds > 0) {
// Poll breve: se il child non è ancora morto, controlliamo poi
// waitpid al prossimo giro; se è morto, drenaiamo finché non c'è
// più nulla da leggere.
const int poll_timeout_ms = child_done ? 100 : 50;
const int pr = ::poll(fds, nfds, poll_timeout_ms);
if (pr > 0) {
for (int i = 0; i < nfds; ++i) {
if (!(fds[i].revents & (POLLIN | POLLHUP | POLLERR))) continue;
bool eof_here = false;
if (fds[i].fd == out_pipe[0]) {
drain_fd_nonblocking(out_pipe[0], result.stdout_text, eof_here, result.output_truncated);
if (eof_here) out_eof = true;
} else if (fds[i].fd == err_pipe[0]) {
drain_fd_nonblocking(err_pipe[0], result.stderr_text, eof_here, result.output_truncated);
if (eof_here) err_eof = true;
}
}
}
// pr == 0 (timeout poll) o pr < 0 con EINTR: proseguiamo il loop.
}
// Condizione di uscita: child morto e nessun ulteriore dato in arrivo.
if (child_done && (out_eof || nfds == 0) && (err_eof || nfds == 0)) {
break;
}
// Sanity: se non c'è più nulla da drenare ma il child non è morto,
// aspettiamo un pochino per non bruciare CPU.
if (!child_done && nfds == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
// Drena residui (le EOF dovrebbero essere già arrivate, ma per sicurezza).
if (!out_eof) { bool e; drain_fd_nonblocking(out_pipe[0], result.stdout_text, e, result.output_truncated); }
if (!err_eof) { bool e; drain_fd_nonblocking(err_pipe[0], result.stderr_text, e, result.output_truncated); }
::close(out_pipe[0]);
::close(err_pipe[0]);
if (child_done) {
result.exited = true;
if (WIFEXITED(status)) {
result.exit_code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
result.exit_code = 128 + WTERMSIG(status);
}
}
fs::remove_all(sandbox_root, ec);
return result;
}
ArchiveInspectionResult analyze_archive_in_sandbox(
const std::string& worker_path,
const std::string& input_path,
int timeout_seconds)
{
ArchiveInspectionResult r;
const auto exec = run_archive_worker_bwrap(worker_path, input_path, timeout_seconds);
if (!exec.launched) {
r.success = false;
r.sandbox_failed = true;
r.reason_code = "ARCHIVE_SANDBOX_LAUNCH_FAILED";
r.detail = exec.stderr_text;
return r;
}
if (exec.output_truncated) {
r.success = false;
r.sandbox_failed = true;
r.reason_code = "ARCHIVE_SANDBOX_OUTPUT_LIMIT";
r.detail = "archive worker exceeded output limit";
return r;
}
if (exec.timed_out) {
r.success = false;
r.sandbox_timeout = true;
r.reason_code = "ARCHIVE_SANDBOX_TIMEOUT";
r.detail = "archive worker timeout";
r.raw_json = exec.stdout_text;
return r;
}
if (!exec.exited || exec.exit_code != 0) {
r.success = false;
r.sandbox_failed = true;
r.reason_code = "ARCHIVE_SANDBOX_ANALYSIS_FAILED";
r.detail = exec.stderr_text.empty() ? "archive worker failed" : exec.stderr_text;
r.raw_json = exec.stdout_text;
return r;
}
r.success = true;
r.raw_json = exec.stdout_text;
r.is_zip = json_has_true(exec.stdout_text, "is_zip");
r.stored_size_mismatch = json_has_true(exec.stdout_text, "stored_size_mismatch");
r.header_mismatch = json_has_true(exec.stdout_text, "header_mismatch");
r.invalid_offset = json_has_true(exec.stdout_text, "invalid_offset");
r.overlapping_entries = json_has_true(exec.stdout_text, "overlapping_entries");
r.nested_archive = json_has_true(exec.stdout_text, "nested_archive");
r.risk_score = json_read_int(exec.stdout_text, "risk_score", 0);
r.reason_code = json_read_string(exec.stdout_text, "reason_code");
r.detail = json_read_string(exec.stdout_text, "detail");
const std::string risk = json_read_string(exec.stdout_text, "risk");
if (risk == "EVASIVE") {
r.risk = ArchiveRisk::EVASIVE;
} else if (risk == "MALFORMED") {
r.risk = ArchiveRisk::MALFORMED;
} else if (risk == "SUSPICIOUS") {
r.risk = ArchiveRisk::SUSPICIOUS;
} else {
r.risk = ArchiveRisk::CLEAN;
}
return r;
}