blob: cc4424f1532c82672f8262a352bafcbf2a3d42af [file]
// Copyright 2025 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "src/starnix/tests/selinux/userspace/audit_checker.h"
#include <sys/socket.h>
#include <algorithm>
#include <regex>
#include <set>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include <fbl/unique_fd.h>
#include <gtest/gtest.h>
#include <linux/audit.h>
#include <linux/netlink.h>
#include <rapidjson/schema.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#include "src/lib/fxl/strings/join_strings.h"
#include "src/lib/fxl/strings/split_string.h"
#include "src/lib/fxl/strings/string_printf.h"
#include "src/lib/json_parser/json_parser.h"
#include "src/starnix/tests/selinux/userspace/audit_utils.h"
#include "src/starnix/tests/syscalls/cpp/test_helper.h"
namespace {
constexpr int kTabSize = 4;
// Buffer size for reading from the netlink socket.
constexpr int kNetlinkBufSize = 4096;
constexpr char kSuccessKey[] = "audit_success";
constexpr char kExpectedFailureKey[] = "audit_failure";
constexpr char kSkipKey[] = "audit_skip";
constexpr char kTestNameKey[] = "name";
constexpr char kTestAuditExpectationsKey[] = "audit_expectations";
constexpr char kExpectationsFile[] = "data/audit_expectations/audit_expectations.json";
const std::regex kAuditLogRegex = std::regex(
R"(avc:\s+(denied|granted)\s+\{\s*([^}]+)\s*\}.*scontext=([^ ]+)\s+tcontext=([^ ]+)\s+tclass=([^ ]+).*)");
void EscapeAuditLog(std::string& audit_log) {
size_t i = 0;
while (i < audit_log.size()) {
if (audit_log.at(i) == '"') {
audit_log.insert(i, 1, '\\');
i += 2;
continue;
}
i += 1;
}
}
void PrintWithTab(int multiplier, const char* format, ...) {
printf("%*s", multiplier * kTabSize, "");
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
fit::result<std::string, AuditChecker::AuditLogEntry> ParseAuditLogString(const std::string& line) {
std::smatch matches;
if (std::regex_search(line, matches, kAuditLogRegex) && matches.size() == 6) {
auto perms =
fxl::SplitStringCopy(matches[2].str(), " ", fxl::WhiteSpaceHandling::kTrimWhitespace,
fxl::SplitResult::kSplitWantNonEmpty);
bool permissive = strstr(line.c_str(), "permissive=1") != 0;
return fit::ok(
AuditChecker::AuditLogEntry{.denied = matches[1].str() == "denied",
.permission = std::set<std::string>{perms.begin(), perms.end()},
.scontext = matches[3].str(),
.tcontext = matches[4].str(),
.tclass = matches[5].str(),
.permissive = permissive});
}
return fit::error("Failed to parse audit log string: " + line);
}
void ExpectationsToJSON(std::vector<std::string> logs, const std::string& test_name) {
printf("\n{\n");
PrintWithTab(1, "\"%s\": \"%s\",\n", kTestNameKey, test_name.c_str());
PrintWithTab(1, "\"%s\": [\n", kTestAuditExpectationsKey);
for (int i = 0; i < (int)logs.size(); i++) {
EscapeAuditLog(logs[i]);
PrintWithTab(2, "\"%s\"", logs[i].c_str());
if (i != (int)logs.size() - 1) {
printf(",");
}
printf("\n");
}
PrintWithTab(1, "]\n");
printf("}\n");
}
void SendStartSentinel() {
auto fork_helper = test_helper::ForkHelper();
pid_t child_pid = fork_helper.RunInForkedProcess([&] {
fbl::unique_fd fd = OpenNetlinkAuditSocket();
if (!fd.is_valid()) {
exit(2);
}
auto send_res = SendUserAuditMessage(fd.get(), 1, "SENTINEL_START");
if (send_res.is_error()) {
fprintf(stderr, "Start sentinel send error %s\n", strerror(send_res.error_value()));
exit(3);
}
exit(0);
});
if (child_pid < 0 || !fork_helper.WaitForChildren()) {
fprintf(stderr, "Start sentinel child error\n");
}
}
void SendEndSentinel() {
auto fork_helper = test_helper::ForkHelper();
pid_t child_pid = fork_helper.RunInForkedProcess([&] {
fbl::unique_fd fd = OpenNetlinkAuditSocket();
if (!fd.is_valid()) {
exit(2);
}
auto send_res = SendUserAuditMessage(fd.get(), 1, "SENTINEL_END");
if (send_res.is_error()) {
fprintf(stderr, "End sentinel send error %s\n", strerror(send_res.error_value()));
exit(3);
}
exit(0);
});
if (child_pid < 0 || !fork_helper.WaitForChildren()) {
fprintf(stderr, "End sentinel end child error\n");
}
}
void DrainAuditLog() {
SendEndSentinel();
fbl::unique_fd fd = OpenNetlinkAuditSocket();
if (!fd.is_valid() || RegisterAsAuditDaemon(fd.get(), getpid()).is_error()) {
return;
}
char buf[kNetlinkBufSize];
// Drain the audit backlog until there is no other message.
while (true) {
auto recv_res = ReceiveNetlinkMessage(fd.get(), buf, sizeof(buf));
if (recv_res.is_error() && recv_res.error_value() == EINTR) {
continue;
}
if (recv_res.value() < NLMSG_LENGTH(0)) {
fprintf(stderr, "audit_drainer: message too short\n");
continue;
}
struct nlmsghdr* header = reinterpret_cast<struct nlmsghdr*>(buf);
if (header->nlmsg_type != AUDIT_USER_AVC) {
continue;
}
std::string message((char*)NLMSG_DATA(header),
(char*)NLMSG_DATA(header) + (recv_res.value() - sizeof(*header)));
if (header->nlmsg_type == AUDIT_USER_AVC) {
if (!strstr(message.c_str(), "SENTINEL_END")) {
continue;
}
break;
}
}
if (UnregisterAuditDaemon(fd.get()).is_error()) {
printf("Failed to unregister\n");
return;
}
}
} // namespace
AuditChecker::AuditChecker() {
// Use `ADD_FAILURE` here to trigger an environment setup failure.
if (!ParseExpectationsFile(kExpectationsFile)) {
ADD_FAILURE() << "Failed to parse audit expectations file.";
}
}
AuditChecker* AuditChecker::with_json_generation() {
auto checker = new AuditChecker;
checker->generate_json_ = true;
return checker;
}
bool AuditChecker::ParseExpectationsFile(const std::string& file_path) {
json_parser::JSONParser parser;
rapidjson::Document doc = parser.ParseFromFile(file_path);
if (parser.HasError()) {
return false;
}
if (!doc.IsObject() || !doc.HasMember(kSuccessKey) || !doc[kSuccessKey].IsArray()) {
return false;
}
if (doc.HasMember(kExpectedFailureKey) && !doc[kExpectedFailureKey].IsArray()) {
return false;
}
for (const auto& failing_test : doc[kExpectedFailureKey].GetArray()) {
if (!failing_test.IsString()) {
return false;
}
expected_failure_tests_.push_back(failing_test.GetString());
}
if (doc.HasMember(kSkipKey) && !doc[kSkipKey].IsArray()) {
return false;
}
for (const auto& skipped_test : doc[kSkipKey].GetArray()) {
if (!skipped_test.IsString()) {
return false;
}
skipped_tests_.push_back(skipped_test.GetString());
}
for (const auto& test_obj : doc[kSuccessKey].GetArray()) {
if (!test_obj.IsObject() || !test_obj.HasMember(kTestNameKey) ||
!test_obj[kTestNameKey].IsString() || !test_obj.HasMember(kTestAuditExpectationsKey) ||
!test_obj[kTestAuditExpectationsKey].IsArray()) {
return false;
}
std::string test_name = test_obj[kTestNameKey].GetString();
std::vector<AuditLogEntry> expected_logs;
for (const auto& log_str_val : test_obj[kTestAuditExpectationsKey].GetArray()) {
if (!log_str_val.IsString()) {
return false;
}
std::string log_str = log_str_val.GetString();
auto parse_result = ParseAuditLogString(log_str);
if (parse_result.is_ok()) {
expected_logs.push_back(parse_result.value());
} else {
return false;
}
}
expectations_map_[test_name] = std::move(expected_logs);
}
return true;
}
bool AuditChecker::AuditLogEntry::operator==(const AuditChecker::AuditLogEntry& other) const =
default;
bool AuditChecker::AuditLogEntry::contains(const AuditLogEntry& other) const {
// TODO: https://fxbug.dev/449714364 - Remove after denials are generated in permissive mode.
bool granted = !denied || permissive;
bool other_granted = !other.denied || other.permissive;
if (other_granted != granted || other.scontext != scontext || other.tcontext != tcontext ||
other.tclass != tclass) {
return false;
}
if (other.permission == permission) {
return true;
}
return std::ranges::includes(permission, other.permission);
}
std::string AuditChecker::AuditLogEntry::ToString() const {
auto perms_str = fxl::JoinStrings(permission, " ");
auto permissive_str = permissive ? "1" : "0";
return fxl::StringPrintf("avc: %s { %s } scontext=%s tcontext=%s tclass=%s permissive=%s",
denied ? "denied" : "granted", perms_str.c_str(), scontext.c_str(),
tcontext.c_str(), tclass.c_str(), permissive_str);
}
fit::result<std::string, std::vector<AuditChecker::AuditLogEntry>> AuditChecker::ReadAuditLogs(
const std::string& test_name) {
std::vector<std::string> raw_logs;
std::vector<AuditLogEntry> parsed_logs;
fbl::unique_fd fd = OpenNetlinkAuditSocket();
if (!fd.is_valid()) {
return fit::error("Failed to open NETLINK_AUDIT socket");
}
auto register_result = RegisterAsAuditDaemon(fd.get(), getpid());
if (register_result.is_error()) {
return fit::error("Failed to register as daemon: " +
std::to_string(register_result.error_value()));
}
bool checked_start = false;
char buf[kNetlinkBufSize];
// Drain the audit backlog until there is no other message.
while (true) {
auto recv_res = ReceiveNetlinkMessage(fd.get(), buf, sizeof(buf));
if (recv_res.is_error() && recv_res.error_value() == EINTR) {
continue;
}
if (recv_res.value() < NLMSG_LENGTH(0)) {
fprintf(stderr, "audit_listener: message too short\n");
continue;
}
struct nlmsghdr* header = (struct nlmsghdr*)buf;
if (header->nlmsg_type != AUDIT_AVC && header->nlmsg_type != AUDIT_USER_AVC) {
continue;
}
std::string message((char*)NLMSG_DATA(header),
(char*)NLMSG_DATA(header) + (recv_res.value() - sizeof(*header)));
if (header->nlmsg_type == AUDIT_USER_AVC) {
if (!checked_start && strstr(message.c_str(), "SENTINEL_START")) {
checked_start = true;
continue;
}
if (!strstr(message.c_str(), "SENTINEL_END")) {
continue;
}
break;
}
// Parse the audit string
auto parse_result = ParseAuditLogString(message);
if (parse_result.is_ok()) {
const auto& entry = parse_result.value();
// If there is a AUDIT_AVC log before the start sentinel, it means
// there is a policy violation outside of the current test.
// The message should be checked, it can be from another test that
// should include an audit expectation.
if (!checked_start) {
fprintf(stderr, "Found AUDIT_AVC log before start sentinel: %s\n", message.c_str());
continue;
}
if (generate_json_) {
raw_logs.push_back(message);
}
fprintf(stderr, "%s\n", message.c_str());
parsed_logs.push_back(entry);
} else {
ADD_FAILURE() << "Failed to parse AUDIT_AVC message: " << parse_result.error_value()
<< "\nMessage: " << message;
}
}
if (UnregisterAuditDaemon(fd.get()).is_error()) {
fprintf(stderr, "Failed to unregister\n");
return fit::ok(parsed_logs);
}
if (!checked_start) {
fprintf(stderr, "Did not find start sentinel\n");
}
if (generate_json_) {
current_test_raw_logs_ = std::move(raw_logs);
}
return fit::ok(parsed_logs);
}
bool AuditChecker::ShouldOnlyDrainAudits(const std::string& test_name) const {
auto found = std::ranges::find(skipped_tests_, test_name);
return found != skipped_tests_.end();
}
bool AuditChecker::IsExpectedToFail(const std::string& test_name) const {
if (!test_helper::IsStarnix()) {
return false;
}
auto found = std::ranges::find(expected_failure_tests_, test_name);
return found != expected_failure_tests_.end();
}
void AuditChecker::OnTestSuiteEnd(const testing::TestSuite& test_suite) {
if (generate_json_) {
// Add a sleep to allow the Linux kernel to print syslog messages before
// printing any audit JSON objects.
sleep(1);
for (auto test_logs : current_test_suite_raw_logs_) {
const auto& logs = std::get<0>(test_logs);
const auto& test_name = std::get<1>(test_logs);
if (!logs.empty() || expectations_map_.count(test_name) > 0) {
ExpectationsToJSON(logs, test_name);
}
}
current_test_suite_raw_logs_.clear();
}
}
void AuditChecker::OnTestStart(const testing::TestInfo& test_info) {
auto test_name = fxl::StringPrintf("%s.%s", test_info.test_suite_name(), test_info.name());
if (ShouldOnlyDrainAudits(test_name)) {
return;
}
SendStartSentinel();
}
void AuditChecker::OnTestEnd(const testing::TestInfo& test_info) {
auto test_name = fxl::StringPrintf("%s.%s", test_info.test_suite_name(), test_info.name());
// If the audit log checking should be skipped, drain the audit logs.
// This can be used for tests that fail on Starnix or do not produce any
// useful logs.
if (ShouldOnlyDrainAudits(test_name)) {
DrainAuditLog();
return;
}
// Skip the audit check if the test is skipped at runtime.
if (::testing::Test::IsSkipped()) {
DrainAuditLog();
return;
}
SendEndSentinel();
bool result = CheckAuditExpectations(test_name);
if (!result && generate_json_) {
// Only update the JSON file if the test fails: upon success, there are no meaningful audit
// updates.
current_test_suite_raw_logs_.emplace_back(std::move(current_test_raw_logs_), test_name);
}
}
bool AuditChecker::CheckAuditExpectations(const std::string& test_name) {
auto read_result = ReadAuditLogs(test_name);
if (read_result.is_error()) {
ADD_FAILURE() << read_result.error_value();
return false;
}
// Fetch the current expectations, if any, for `test_name`.
auto it = expectations_map_.find(test_name);
auto expected_logs = it != expectations_map_.end() ? it->second : std::vector<AuditLogEntry>();
auto actual_logs = read_result.value();
if (test_helper::IsStarnix()) {
// TODO: Introduce an explicit ignore-audit-logs scope so that permissive logs can be compared.
std::erase_if(expected_logs, [](auto& x) { return x.permissive; });
std::erase_if(actual_logs, [](auto& x) { return !x.denied || x.permissive; });
}
// Expectations are generally expected to match in-order, so iterate over the two lists to
// emit each line with a prefix indicating the difference between the
// observed and expected lists.
std::string audit_diff;
auto actual_it = actual_logs.begin();
auto expected_it = expected_logs.begin();
bool audit_logs_match = true;
while (actual_it != actual_logs.end() && expected_it != expected_logs.end()) {
if (test_helper::IsStarnix()) {
// Account for Starnix only checking, and audit logging, one permission
// at at time, where Linux may check several in a single operation.
if (expected_it->contains(*actual_it)) {
// Observed audit log matches at least one permission in the next expectation, so emit it
// with no prefix.
if (expected_it->permission == actual_it->permission) {
// Move to next expectation in case of exact match.
expected_it++;
} else if (actual_it->denied) {
// Move to next expectation, if any, in case of partial match denied access, since the
// first denial will prevent the subsequent checks being made.
expected_it++;
} else {
// If the expectation is granted (whether permissive or denied) then we expect the other
// permissions to also be checked, so just remove the matched permission.
std::string found_perm = *actual_it->permission.begin();
if (expected_it->permission.erase(found_perm) != 1u) {
ADD_FAILURE() << "Expected to erase exactly 1 permission, but failed.";
return false;
}
}
audit_diff += "\n " + actual_it->ToString();
actual_it++;
continue;
}
} else {
// Linux is the baseline, so expect audit logs to match line for line.
if (*expected_it == *actual_it) {
audit_diff += "\n " + actual_it->ToString();
actual_it++;
expected_it++;
continue;
}
}
audit_logs_match = false;
if (auto it = std::find_if(
expected_it, expected_logs.end(),
[actual_it](const AuditLogEntry& expected) { return expected.contains(*actual_it); });
it != expected_logs.end()) {
// Items don't match, but the observed audit log matches an expectation that appears later.
// Emit all the intervening expected logs with a prefix indicating they're missing.
for (; !expected_it->contains(*actual_it); expected_it++) {
audit_diff += "\n-" + expected_it->ToString();
}
} else {
// Item doesn't appear in the expected audit logs at all.
audit_diff += "\n+" + actual_it->ToString();
actual_it++;
}
}
for (; expected_it != expected_logs.end(); expected_it++) {
audit_diff += "\n-" + expected_it->ToString();
audit_logs_match = false;
}
for (; actual_it != actual_logs.end(); actual_it++) {
audit_diff += "\n+" + actual_it->ToString();
audit_logs_match = false;
}
bool expect_success = !IsExpectedToFail(test_name);
if (audit_logs_match == expect_success) {
return true;
}
if (generate_json_) {
return false;
}
if (!expect_success) {
// Audit logs matched expectations, despite us expecting them to mismatch.
ADD_FAILURE() << "Got matching audit logs, expected mismatch, for test: " << test_name;
return false;
}
ADD_FAILURE() << "Audit logs mismatch. Expected " << expected_logs.size() << ", got "
<< actual_logs.size() << "." << std::endl
<< "Diff of observed from expected audit logs: " << audit_diff;
return false;
}