/* * 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; }