glove/tests/container/linux_exec_test.cpp
2026-08-14 18:40:38 -06:00

301 lines
12 KiB
C++

// Stage 3 + Step A (Linux): `glove exec` contains a real agent via clone3 +
// namespaces + a strict allow-list rootfs, with stdio inherited. We run
// /usr/bin/sh through exec_contained and assert the whole posture in one shot,
// writing the results into the workspace for the host to read back. Requires the
// privileged Docker environment the other Linux spawner tests use.
#include "glove/container/profile.hpp"
#include "glove/container/spawner.hpp"
#include "glove/net/egress_proxy.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
namespace {
#define REQUIRE(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", #cond, __FILE__, __LINE__); \
return 1; \
} \
} while (0)
auto contains(const std::string& hay, std::string_view needle) -> bool {
return hay.find(needle) != std::string::npos;
}
auto egress_probe(
const std::filesystem::path& result, std::uint16_t origin_port, std::string_view authorization
) -> int {
const char* proxy_url = std::getenv("HTTPS_PROXY");
if (proxy_url == nullptr) {
return 20;
}
const std::string_view url{proxy_url};
const auto colon = url.rfind(':');
if (colon == std::string_view::npos) {
return 21;
}
const auto proxy_port = static_cast<std::uint16_t>(
std::strtoul(std::string{url.substr(colon + 1)}.c_str(), nullptr, 10)
);
const int connection = ::socket(AF_INET, SOCK_STREAM, 0);
if (connection < 0) {
return 22;
}
::sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(proxy_port);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::connect(connection, reinterpret_cast<const ::sockaddr*>(&address), sizeof(address)) < 0) {
::close(connection);
return 23;
}
const std::string request =
"CONNECT localhost:" + std::to_string(origin_port) +
" HTTP/1.1\r\nHost: localhost\r\nProxy-Authorization: " + std::string{authorization} +
"\r\n\r\n";
if (::send(connection, request.data(), request.size(), MSG_NOSIGNAL) !=
static_cast<ssize_t>(request.size())) {
::close(connection);
return 24;
}
std::array<char, 4096> buffer{};
const auto header_size = ::recv(connection, buffer.data(), buffer.size(), 0);
if (header_size <= 0 ||
std::string_view{buffer.data(), static_cast<std::size_t>(header_size)}.find(" 200 ") ==
std::string_view::npos) {
if (header_size > 0) {
std::fprintf(
stderr,
"egress probe response: %.*s\n",
static_cast<int>(header_size),
buffer.data()
);
} else {
std::fprintf(stderr, "egress probe recv=%zd errno=%d\n", header_size, errno);
}
::close(connection);
return 25;
}
constexpr std::string_view payload = "private-loopback-broker";
if (::send(connection, payload.data(), payload.size(), MSG_NOSIGNAL) !=
static_cast<ssize_t>(payload.size())) {
::close(connection);
return 26;
}
const auto echoed = ::recv(connection, buffer.data(), buffer.size(), 0);
::close(connection);
if (echoed != static_cast<ssize_t>(payload.size()) ||
std::string_view{buffer.data(), static_cast<std::size_t>(echoed)} != payload) {
return 27;
}
std::ofstream out{result};
out << "egress=audited-private-loopback\n";
return out.good() ? 0 : 28;
}
struct echo_origin {
int listener = -1;
std::uint16_t port = 0;
std::thread worker;
echo_origin() = default;
echo_origin(const echo_origin&) = delete;
auto operator=(const echo_origin&) -> echo_origin& = delete;
echo_origin(echo_origin&& other) noexcept
: listener{std::exchange(other.listener, -1)},
port{other.port},
worker{std::move(other.worker)} {}
~echo_origin() {
if (listener >= 0) {
::shutdown(listener, SHUT_RDWR);
::close(listener);
}
if (worker.joinable()) {
worker.join();
}
}
};
auto start_echo_origin() -> echo_origin {
echo_origin origin;
origin.listener = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (origin.listener < 0) {
return origin;
}
::sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = 0;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(origin.listener, reinterpret_cast<const ::sockaddr*>(&address), sizeof(address)) <
0 ||
::listen(origin.listener, 1) < 0) {
::close(origin.listener);
origin.listener = -1;
return origin;
}
socklen_t size = sizeof(address);
if (::getsockname(origin.listener, reinterpret_cast<::sockaddr*>(&address), &size) < 0) {
::close(origin.listener);
origin.listener = -1;
return origin;
}
origin.port = ntohs(address.sin_port);
origin.worker = std::thread{[listener = origin.listener] {
const int client = ::accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
if (client < 0) {
return;
}
std::array<char, 4096> buffer{};
const auto count = ::recv(client, buffer.data(), buffer.size(), 0);
if (count > 0) {
static_cast<void>(::send(client, buffer.data(), static_cast<std::size_t>(count), 0));
}
::close(client);
}};
return origin;
}
auto run(const std::filesystem::path& self) -> int {
const std::string id = std::to_string(::getpid());
// Keep the fixture outside /tmp, which the container shadows with a fresh
// tmpfs, but inside CTest's runner-owned working directory. A neighboring
// file proves non-granted paths are absent rather than merely read-only.
const auto base = std::filesystem::current_path();
const auto ws = base / ("glove_ws_" + id);
const auto home = ws / "home";
const auto outside = base / ("glove_outside_" + id);
std::error_code filesystem_error;
REQUIRE(std::filesystem::create_directories(home, filesystem_error));
REQUIRE(!filesystem_error);
{
std::ofstream marker{outside};
marker << "OUTSIDE_PROBE_VALUE";
REQUIRE(marker.good());
}
REQUIRE(::setenv("GLOVE_TEST_HOST_SECRET", "must-not-cross", 1) == 0);
glove::container::profile prof;
prof.filesystem.push_back({.path = ws.string(), .writable = true});
prof.home_dir = home.string();
prof.work_dir = ws.string();
prof.environment = {"PATH=/usr/bin:/bin:/usr/sbin:/sbin"};
// One agent invocation probes the whole perimeter and writes a result line.
std::string script;
script += "{ ";
script += "echo pid=$$; "; // PID namespace
script += "echo cwd=$PWD; "; // work_dir
script += "echo home=$HOME; "; // scratch HOME
script += "head -c4 '" + outside.string() +
"' >/dev/null 2>&1 && echo outside=BAD || echo outside=denied; ";
script += "touch /etc/glove_evil 2>/dev/null && echo writeetc=BAD || echo writeetc=denied; ";
script += "touch ./wok 2>/dev/null && echo writews=ok || echo writews=no; ";
script += "test -s /proc/net/route && echo network=BAD || echo network=denied; ";
script +=
"env | grep '^GLOVE_TEST_HOST_SECRET=' >/dev/null && echo env=BAD || echo env=scrubbed; ";
script += "} > '" + (ws / "result").string() + "'";
auto code = glove::container::exec_contained(prof, {"/usr/bin/sh", "-c", script});
REQUIRE(code.has_value());
REQUIRE(*code == 0);
std::ifstream in{ws / "result"};
REQUIRE(in.good());
std::string out((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
std::fprintf(
stderr, "--- agent perimeter probe ---\n%s-----------------------------\n", out.c_str()
);
REQUIRE(contains(out, "pid=1")); // contained: PID 1 of its own ns
REQUIRE(contains(out, "cwd=" + ws.string())); // started in the workspace
REQUIRE(contains(out, "home=" + home.string())); // HOME is the scratch home
REQUIRE(contains(out, "outside=denied")); // unrelated host file is absent
REQUIRE(contains(out, "writeetc=denied")); // write-narrow: /etc not writable
REQUIRE(contains(out, "writews=ok")); // workspace writable
REQUIRE(contains(out, "network=denied")); // no route leaves the network namespace
REQUIRE(contains(out, "env=scrubbed")); // host credentials are not inherited
auto origin = start_echo_origin();
REQUIRE(origin.listener >= 0);
glove::net::egress_options egress_options;
egress_options.allow = {{.host = "localhost", .port = origin.port, .allow_private = true}};
bool audited_allow = false;
egress_options.on_event = [&audited_allow](
const glove::net::egress_event& event
) -> std::expected<void, std::string> {
audited_allow = event.allowed && event.host == "localhost";
return {};
};
auto proxy = glove::net::start_egress_proxy(std::move(egress_options));
REQUIRE(proxy.has_value());
glove::container::profile online = prof;
// LeakSanitizer cannot trace the nested exec after it enters the isolated
// PID namespace. Keep ASan/UBSan active and disable only child leak tracing.
online.environment.emplace_back("ASAN_OPTIONS=halt_on_error=1:abort_on_error=1:detect_leaks=0");
online.proxy = glove::container::proxy_settings{
.port = (*proxy)->port(),
.url = (*proxy)->proxy_url(),
};
const auto egress_result = ws / "egress-result";
auto egress_code = glove::container::exec_contained(
online,
{
self.string(),
"--egress-probe",
egress_result.string(),
std::to_string(origin.port),
(*proxy)->proxy_authorization(),
}
);
REQUIRE(egress_code.has_value());
if (*egress_code != 0) {
std::fprintf(stderr, "egress probe exit=%d\n", *egress_code);
}
REQUIRE(*egress_code == 0);
std::ifstream egress_in{egress_result};
REQUIRE(egress_in.good());
const std::string egress_out{
std::istreambuf_iterator<char>{egress_in}, std::istreambuf_iterator<char>{}
};
REQUIRE(contains(egress_out, "egress=audited-private-loopback"));
REQUIRE(audited_allow);
REQUIRE(std::filesystem::remove_all(ws, filesystem_error) > 0);
REQUIRE(!filesystem_error);
REQUIRE(std::filesystem::remove(outside, filesystem_error));
REQUIRE(!filesystem_error);
REQUIRE(::unsetenv("GLOVE_TEST_HOST_SECRET") == 0);
return 0;
}
} // namespace
auto main(int argc, char** argv) -> int {
if (argc == 5 && std::string_view{argv[1]} == "--egress-probe") {
return egress_probe(
argv[2], static_cast<std::uint16_t>(std::strtoul(argv[3], nullptr, 10)), argv[4]
);
}
std::error_code error;
const auto self = std::filesystem::canonical(argv[0], error);
REQUIRE(!error);
return run(self);
}