diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f002e6eb7b..0c96258726 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -71,6 +71,15 @@ jobs: collector-builder-tag: ${{ needs.build-builder-image.outputs.collector-builder-tag }} secrets: inherit + plugin-validator: + uses: ./.github/workflows/plugin-validator.yml + permissions: + contents: read + needs: + - build-builder-image + with: + collector-builder-tag: ${{ needs.build-builder-image.outputs.collector-builder-tag }} + integration-tests: uses: ./.github/workflows/integration-tests.yml with: diff --git a/.github/workflows/plugin-validator.yml b/.github/workflows/plugin-validator.yml new file mode 100644 index 0000000000..ba3e5baa14 --- /dev/null +++ b/.github/workflows/plugin-validator.yml @@ -0,0 +1,75 @@ +name: Plugin correctness validator + +on: + workflow_call: + inputs: + collector-builder-tag: + type: string + required: true + description: The builder tag to use in the build + +permissions: + contents: read + +jobs: + replay: + name: Plugin corpus (ASan/UBSan, ${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + arch: [amd64, arm64] + runs-on: ${{ (matrix.arch == 'arm64' && 'ubuntu-24.04-arm') || 'ubuntu-24.04' }} + timeout-minutes: 45 + container: + image: quay.io/stackrox-io/collector-builder:${{ inputs.collector-builder-tag }} + defaults: + run: + shell: bash + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + + steps: + - uses: actions/checkout@v4 + + - name: Initialize required submodules and record revisions + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git submodule update --init --depth 1 falcosecurity-libs collector/proto/third_party/stackrox + mkdir -p artifacts + { + git rev-parse HEAD + git submodule status falcosecurity-libs collector/proto/third_party/stackrox + echo "Builder: quay.io/stackrox-io/collector-builder:${{ inputs.collector-builder-tag }}" + uname -sm + c++ --version + } | tee artifacts/revisions.txt + + - name: Configure sanitizer replay target + run: | + cmake -S . -B cmake-build \ + -DBUILD_PLUGIN_REPLAY_TESTS=ON \ + -DADDRESS_SANITIZER=ON \ + -DCMAKE_BUILD_TYPE=Debug \ + -DDISABLE_PROFILING=ON \ + '-DCMAKE_C_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer' \ + 2>&1 | tee artifacts/configure.log + + - name: Build validator and production plugin + run: | + cmake --build cmake-build --target ContainerPluginReplayTest --parallel 2 \ + 2>&1 | tee artifacts/build.log + + - name: Replay corpus + run: | + bash collector/test/plugin-replay/run-corpus.sh \ + "$GITHUB_WORKSPACE/cmake-build" "$GITHUB_WORKSPACE/artifacts/replay" + + - name: Upload validation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: plugin-validator-${{ matrix.arch }}-${{ github.run_attempt }} + path: artifacts/ + if-no-files-found: warn + retention-days: 14 diff --git a/collector/CMakeLists.txt b/collector/CMakeLists.txt index adc11c1735..57e2b81602 100644 --- a/collector/CMakeLists.txt +++ b/collector/CMakeLists.txt @@ -104,4 +104,12 @@ set(MODERN_BPF_DEBUG_MODE ${BPF_DEBUG_MODE} CACHE BOOL "Enable BPF debug prints" set(MODERN_BPF_EXCLUDE_PROGS "^(openat2|ppoll|setsockopt|io_uring_setup|nanosleep|pread64|preadv|pwritev|read|readv|writev|recv|process_vm_readv|process_vm_writev)$" CACHE STRING "Set of syscalls to exclude from modern bpf engine " FORCE) +option(BUILD_PLUGIN_REPLAY_TESTS "Build isolated plugin replay tests" OFF) +if(BUILD_PLUGIN_REPLAY_TESTS) + set(HAS_ENGINE_TEST_INPUT ON) +endif() add_subdirectory(${FALCO_DIR} falco) + +if(BUILD_PLUGIN_REPLAY_TESTS) + add_subdirectory(test/plugin-replay) +endif() diff --git a/collector/test/plugin-replay/CMakeLists.txt b/collector/test/plugin-replay/CMakeLists.txt new file mode 100644 index 0000000000..0d7918f309 --- /dev/null +++ b/collector/test/plugin-replay/CMakeLists.txt @@ -0,0 +1,22 @@ +# Reuse the pinned upstream fixture without enabling its entire test suite. +find_package(GTest CONFIG REQUIRED) +set(FALCO_TEST_DIR "${FALCO_DIR}/userspace/libsinsp/test") +configure_file("${FALCO_TEST_DIR}/libsinsp_test_var.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/libsinsp_test_var.h") +file(GLOB FALCO_TEST_HELPERS "${FALCO_TEST_DIR}/helpers/*.cpp") +add_library(collector_replay_support STATIC + "${FALCO_TEST_DIR}/sinsp_with_test_input.cpp" + "${FALCO_TEST_DIR}/test_utils.cpp" + ${FALCO_TEST_HELPERS}) +target_include_directories(collector_replay_support PUBLIC + "${FALCO_DIR}" "${FALCO_TEST_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") +target_link_libraries(collector_replay_support PUBLIC sinsp GTest::gtest) + +add_executable(ContainerPluginReplayTest ContainerPluginReplayTest.cpp) +target_link_libraries(ContainerPluginReplayTest PRIVATE + collector_replay_support collector_lib GTest::gtest_main) +add_dependencies(ContainerPluginReplayTest collector-container-plugin) +add_test(NAME ContainerPluginReplayTest COMMAND ContainerPluginReplayTest) +set_tests_properties(ContainerPluginReplayTest PROPERTIES + TIMEOUT 30 + ENVIRONMENT "ROX_COLLECTOR_CONTAINER_PLUGIN_PATH=$") diff --git a/collector/test/plugin-replay/ContainerPluginReplayTest.cpp b/collector/test/plugin-replay/ContainerPluginReplayTest.cpp new file mode 100644 index 0000000000..e9d7c159ff --- /dev/null +++ b/collector/test/plugin-replay/ContainerPluginReplayTest.cpp @@ -0,0 +1,376 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +// The upstream fixture macro collides with protobuf's ErrorLocation enum. +#undef DEFAULT_VALUE + +#include "Corpus.h" +#include "NetworkSignalHandler.h" +#include "Utility.h" + +namespace collector { +namespace { + +const std::string kID(64, 'a'); +const std::string kPath = "/kubepods/burstable/pod123/" + kID; +const std::string kShortID = kID.substr(0, 12); + +class ContainerPluginReplayTest : public sinsp_with_test_input { + protected: + void SetUp() override { + const char* path = std::getenv("ROX_COLLECTOR_CONTAINER_PLUGIN_PATH"); + ASSERT_NE(path, nullptr); + plugin_ = m_inspector.register_plugin(path); + std::string error; + ASSERT_TRUE(plugin_->init("{}", error)) << error; + } + + void SeedThread(int64_t tid, int64_t vpid, + const std::vector& cgroups) { + auto thread = create_threadinfo(tid, tid, 0, tid, vpid, vpid, + "replay", "/bin/replay", "/bin/replay", + increasing_ts(), 0, 0, {}, 0, {}, "/"); + const auto bytes = test_utils::to_null_delimited(cgroups); + ASSERT_LE(bytes.size(), sizeof(thread.cgroups.path)); + std::memcpy(thread.cgroups.path, bytes.data(), bytes.size()); + thread.cgroups.len = bytes.size(); + add_thread(thread, {}); + } + + void Open() { + open_inspector(); // Falco invokes plugin_capture_open; tests never do so directly. + sinsp_filter_check_list checks; + checks.add_filter_check(m_inspector.new_generic_filtercheck()); + checks.add_filter_check(sinsp_plugin::new_filtercheck(plugin_)); + auto factory = std::make_shared(&m_inspector, checks); + sinsp_filter_compiler compiler(factory, "container.id != host"); + filter_ = compiler.compile(); + } + + void ExpectAttribution(int64_t tid, const std::string& expected, bool accepted) { + auto* event = generate_random_event(tid); + ASSERT_NE(event, nullptr); + EXPECT_EQ(GetContainerID(event), expected); + EXPECT_EQ(filter_->run(event), accepted); + } + + void ExpectConnection(int64_t tid, const std::string& expected, bool accepted) { + ASSERT_NE(generate_socket_exit_event({}, tid), nullptr); + auto* event = generate_connect_exit_event({}, tid); + ASSERT_NE(event, nullptr); + ASSERT_NE(event->get_thread_info(), nullptr); + ASSERT_FALSE(event->get_thread_info()->is_invalid()); + ASSERT_NE(event->get_fd_info(), nullptr); + EXPECT_EQ(GetContainerID(event), expected); + EXPECT_EQ(filter_->run(event), accepted); + } + + void ImportLateThread(int64_t tid, const std::vector& cgroups) { + // Models a successful late /proc lookup by importing its resulting state. + // TEST_INPUT has no proc_get callback; this is not an actual /proc lookup. + auto thread = m_inspector.get_threadinfo_factory().create(); + thread->m_tid = tid; + thread->m_pid = tid; + thread->m_ptid = 0; + thread->m_vtid = tid; + thread->m_vpid = tid; + thread->m_clone_ts = increasing_ts(); + thread->m_comm = "late-import"; + thread->m_exepath = "/bin/late-import"; + thread->set_cgroups(cgroups); + ASSERT_NE(m_inspector.m_thread_manager->add_thread(std::move(thread), false), nullptr); + } + + void ExpectHostPIDChildConnectionTracked(bool include_child_event) { + SeedThread(200, 200, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_clone_x_event(201, 200, 200, 0, 0, 200, 200, + "parent", {"memory=" + kPath}, PPME_SYSCALL_FORK_20_X), + nullptr); + if (include_child_event) { + ASSERT_NE(generate_clone_x_event(0, 201, 201, 200, 0, 201, 201, + "child", {"memory=" + kPath}, PPME_SYSCALL_FORK_20_X), + nullptr); + } + auto tracker = std::make_shared(); + system_inspector::Stats stats; + NetworkSignalHandler handler(&m_inspector, tracker, &stats); + ASSERT_NE(generate_socket_exit_event({}, 201), nullptr); + auto* event = generate_connect_exit_event({}, 201); + ASSERT_NE(event, nullptr); + ASSERT_TRUE(filter_->run(event)); + EXPECT_EQ(handler.HandleSignal(event), SignalHandler::PROCESSED); + const auto connections = tracker->FetchConnState(false, false); + ASSERT_EQ(connections.size(), 1U); + EXPECT_EQ(connections.begin()->first.container(), kShortID); + } + + std::shared_ptr plugin_; + std::unique_ptr filter_; +}; + +TEST_F(ContainerPluginReplayTest, StartupHostContainerAndHostPID) { + SeedThread(100, 100, {"memory=/"}); + SeedThread(200, 1, {"memory=" + kPath}); + SeedThread(300, 300, {"memory=" + kPath}); + Open(); + ExpectAttribution(100, "", false); + ExpectAttribution(200, kShortID, true); + ExpectAttribution(300, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, MatchingCgroupBeforeNonmatch) { + SeedThread(200, 1, {"memory=" + kPath, "cpuset=/"}); + Open(); + ExpectAttribution(200, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, MatchingCgroupAfterNonmatch) { + SeedThread(200, 1, {"cpuset=/", "memory=" + kPath}); + Open(); + ExpectAttribution(200, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, InstalledFilterRejectsHostAndAcceptsContainer) { + SeedThread(100, 100, {"memory=/"}); + SeedThread(200, 1, {"memory=" + kPath}); + Open(); + m_inspector.set_filter(std::move(filter_), "container.id != host"); + add_filtered_event_advance_ts(increasing_ts(), 100, PPME_SOCKET_GETSOCKNAME_X, 0); + auto* event = generate_random_event(200); + ASSERT_NE(event, nullptr); + EXPECT_EQ(GetContainerID(event), kShortID); +} + +TEST_F(ContainerPluginReplayTest, ForkWithoutExecAttributesFirstConnection) { + SeedThread(200, 1, {"memory=" + kPath}); + Open(); + // Replay parent and child fork exits; no exec event is generated. + ASSERT_NE(generate_clone_x_event(201, 200, 200, 0, 0, 1, 1, + "parent", {"memory=" + kPath}, + PPME_SYSCALL_FORK_20_X), + nullptr); + ASSERT_NE(generate_clone_x_event(0, 201, 201, 200, 0, 2, 2, + "child", {"memory=" + kPath}, + PPME_SYSCALL_FORK_20_X), + nullptr); + ASSERT_NE(generate_socket_exit_event({}, 201), nullptr); + auto* event = generate_connect_exit_event({}, 201); + ASSERT_NE(event, nullptr); + ASSERT_NE(event->get_fd_info(), nullptr); + EXPECT_EQ(GetContainerID(event), kShortID); + EXPECT_TRUE(filter_->run(event)); +} + +class StartupCorpusTest : public ContainerPluginReplayTest, + public ::testing::WithParamInterface {}; + +TEST_P(StartupCorpusTest, AttributionAndFilter) { + const auto& scenario = GetParam(); + SeedThread(200, 200, scenario.cgroups); + Open(); + ExpectAttribution(200, scenario.expected_id, !scenario.expected_id.empty()); +} + +INSTANTIATE_TEST_SUITE_P(Corpus, StartupCorpusTest, + ::testing::ValuesIn(replay_corpus::StartupCases()), + [](const auto& info) { return info.param.name; }); + +class ForkCorpusTest : public ContainerPluginReplayTest, + public ::testing::WithParamInterface {}; + +TEST_P(ForkCorpusTest, FirstConnectionAttributionAndFilter) { + using namespace replay_corpus; + const auto& scenario = GetParam(); + const bool container = scenario.origin != Origin::Host; + const bool pidns = scenario.origin == Origin::PIDNamespaceContainer; + const std::vector cgroups = {container ? "memory=" + kPath : "memory=/"}; + SeedThread(200, pidns ? 1 : 200, cgroups); + Open(); + const uint32_t flags = pidns ? PPM_CL_CHILD_IN_PIDNS : 0; + auto parent = [&]() { + ASSERT_NE(generate_clone_x_event(pidns ? 2 : 201, 200, 200, 0, flags, + pidns ? 1 : 200, pidns ? 1 : 200, + "parent", cgroups, scenario.event_type), + nullptr); + }; + auto child = [&]() { + ASSERT_NE(generate_clone_x_event(0, 201, 201, 200, flags, + pidns ? 2 : 201, pidns ? 2 : 201, + "child", cgroups, scenario.event_type), + nullptr); + }; + switch (scenario.order) { + case ForkOrder::ParentThenChild: + parent(); + child(); + break; + case ForkOrder::ChildThenParent: + child(); + parent(); + break; + case ForkOrder::ChildOnly: + child(); + break; + case ForkOrder::ParentOnly: + parent(); + break; + } + // Prove Falco already has enough information; missing attribution here cannot + // be excused by a missing thread or missing cgroup payload in the scenario. + auto thread = m_inspector.m_thread_manager->find_thread(201, true); + ASSERT_NE(thread, nullptr); + ASSERT_FALSE(thread->is_invalid()); + ASSERT_FALSE(thread->cgroups().empty()); + EXPECT_EQ(thread->cgroups().front().second, container ? kPath : "/"); + const auto& field = m_inspector.m_thread_manager->dynamic_fields()->fields().at("container_id"); + std::string cached_id; + thread->get_dynamic_field(field.new_accessor(), cached_id); + RecordProperty("cached_id_before_connection", cached_id); + RecordProperty("child_cgroup", thread->cgroups().front().second); + ExpectConnection(201, container ? kShortID : "", container); +} + +INSTANTIATE_TEST_SUITE_P(Corpus, ForkCorpusTest, + ::testing::ValuesIn(replay_corpus::ForkCases()), + [](const auto& info) { return info.param.name; }); + +TEST_F(ContainerPluginReplayTest, LateImportedHostIsRejected) { + SeedThread(1, 1, {"memory=/"}); + Open(); + ImportLateThread(200, {"memory=/"}); + ExpectConnection(200, "", false); +} + +TEST_F(ContainerPluginReplayTest, LateImportedContainerIsAttributed) { + SeedThread(1, 1, {"memory=/"}); + Open(); + ImportLateThread(200, {"memory=" + kPath}); + ExpectConnection(200, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, ParentForkHostChildRejectedByInstalledFilter) { + SeedThread(200, 200, {"memory=/"}); + Open(); + ASSERT_NE(generate_clone_x_event(201, 200, 200, 0, 0, 200, 200, + "parent", {"memory=/"}, PPME_SYSCALL_FORK_20_X), + nullptr); + auto child = m_inspector.m_thread_manager->find_thread(201, true); + ASSERT_NE(child, nullptr); + ASSERT_FALSE(child->is_invalid()); + m_inspector.set_filter(std::move(filter_), "container.id != host"); + // The child-side fork event was dropped; this is a socket event on the host. + add_filtered_event_advance_ts(increasing_ts(), 201, PPME_SOCKET_GETSOCKNAME_X, 0); +} + +TEST_F(ContainerPluginReplayTest, ChildForkRepairsPreviouslyUncachedHostPIDChild) { + SeedThread(200, 200, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_clone_x_event(201, 200, 200, 0, 0, 200, 200, + "parent", {"memory=" + kPath}, PPME_SYSCALL_FORK_20_X), + nullptr); + // Child traffic may precede the observed child-side event. Do not assert it + // here: the parent-only corpus separately checks that failing interval. + ASSERT_NE(generate_socket_exit_event({}, 201), nullptr); + ASSERT_NE(generate_clone_x_event(0, 201, 201, 200, 0, 201, 201, + "child", {"memory=" + kPath}, PPME_SYSCALL_FORK_20_X), + nullptr); + ExpectConnection(201, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, ExecRefreshesContainerAttribution) { + SeedThread(200, 1, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_execve_enter_and_exit_event(0, 200, 200, 200, 0, + "/bin/new", "new", "/bin/new", {"memory=/docker/" + replay_corpus::kB}), + nullptr); + ExpectConnection(200, "bbbbbbbbbbbb", true); +} + +TEST_F(ContainerPluginReplayTest, ExecveatRefreshesContainerAttribution) { + SeedThread(200, 1, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_execveat_enter_and_exit_event(0, 200, 200, 200, 0, + "/bin/new", "new", "/bin/new", {"memory=/docker/" + replay_corpus::kB}), + nullptr); + ExpectConnection(200, "bbbbbbbbbbbb", true); +} + +TEST_F(ContainerPluginReplayTest, FailedExecPreservesAttribution) { + SeedThread(200, 1, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_execve_enter_and_exit_event(-2, 200, 200, 200, 0, + "/missing", "missing", "/missing", {}), + nullptr); + ExpectConnection(200, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, TIDReuseAcrossContainersDoesNotRetainOldID) { + SeedThread(1, 1, {"memory=/"}); + SeedThread(200, 200, {"memory=" + kPath}); + Open(); + ExpectConnection(200, kShortID, true); + remove_thread(200, 1); + ASSERT_EQ(m_inspector.m_thread_manager->find_thread(200, true), nullptr); + ASSERT_NE(generate_clone_x_event(0, 200, 200, 1, 0, 200, 200, + "replacement", {"memory=/docker/" + replay_corpus::kB}, PPME_SYSCALL_FORK_20_X), + nullptr); + ExpectConnection(200, "bbbbbbbbbbbb", true); +} + +TEST_F(ContainerPluginReplayTest, TIDReuseFromContainerToHostIsRejected) { + SeedThread(1, 1, {"memory=/"}); + SeedThread(200, 200, {"memory=" + kPath}); + Open(); + remove_thread(200, 1); + ASSERT_EQ(m_inspector.m_thread_manager->find_thread(200, true), nullptr); + ASSERT_NE(generate_clone_x_event(0, 200, 200, 1, 0, 200, 200, + "replacement", {"memory=/"}, PPME_SYSCALL_FORK_20_X), + nullptr); + ExpectConnection(200, "", false); +} + +TEST_F(ContainerPluginReplayTest, ThreadCloneWithoutExecIsAttributed) { + SeedThread(200, 1, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_clone_x_event(0, 201, 200, 0, + PPM_CL_CLONE_THREAD | PPM_CL_CHILD_IN_PIDNS, 2, 1, + "worker", {"memory=" + kPath}, PPME_SYSCALL_CLONE_20_X), + nullptr); + ExpectConnection(201, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, VforkChildExitBeforeParentDoesNotResurrectChild) { + SeedThread(1, 1, {"memory=/"}); + SeedThread(200, 200, {"memory=" + kPath}); + Open(); + ASSERT_NE(generate_clone_x_event(0, 201, 201, 200, PPM_CL_CLONE_VFORK, 201, 201, + "child", {"memory=" + kPath}, PPME_SYSCALL_VFORK_20_X), + nullptr); + ExpectConnection(201, kShortID, true); + remove_thread(201, 200); + ASSERT_EQ(m_inspector.m_thread_manager->find_thread(201, true), nullptr); + ASSERT_NE(generate_clone_x_event(201, 200, 200, 0, PPM_CL_CLONE_VFORK, 200, 200, + "parent", {"memory=" + kPath}, PPME_SYSCALL_VFORK_20_X), + nullptr); + EXPECT_EQ(m_inspector.m_thread_manager->find_thread(201, true), nullptr); + ExpectConnection(200, kShortID, true); +} + +TEST_F(ContainerPluginReplayTest, HostPIDConnectionTrackedWithBothForkEvents) { + ExpectHostPIDChildConnectionTracked(true); +} + +TEST_F(ContainerPluginReplayTest, HostPIDConnectionTrackedWithoutChildForkEvent) { + ExpectHostPIDChildConnectionTracked(false); +} + +} // namespace +} // namespace collector diff --git a/collector/test/plugin-replay/Corpus.h b/collector/test/plugin-replay/Corpus.h new file mode 100644 index 0000000000..e1cf33a7f6 --- /dev/null +++ b/collector/test/plugin-replay/Corpus.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace collector::replay_corpus { + +// Explicit expectations: never derive the oracle with the plugin's extractor. +inline const std::string kA(64, 'a'); +inline const std::string kB(64, 'b'); +inline const std::string kContainer = "/kubepods/burstable/pod123/" + kA; + +struct StartupCase { + std::string name; + std::vector cgroups; + std::string expected_id; +}; + +inline void PrintTo(const StartupCase& scenario, std::ostream* out) { + *out << scenario.name; +} + +inline std::vector StartupCases() { + return { + {"Empty", {}, ""}, + {"HostRoot", {"memory=/", "cpuset=/"}, ""}, + {"HostSystemd", {"memory=/system.slice/kubelet.service"}, ""}, + {"DockerCgroupfs", {"memory=/docker/" + kA}, "aaaaaaaaaaaa"}, + {"DockerSystemd", {"memory=/system.slice/docker-" + kA + ".scope"}, "aaaaaaaaaaaa"}, + {"CrioSystemd", {"memory=/kubepods.slice/crio-" + kA + ".scope"}, "aaaaaaaaaaaa"}, + {"ContainerdSystemd", {"memory=/kubepods.slice/cri-containerd-" + kA + ".scope"}, "aaaaaaaaaaaa"}, + {"PodmanSystemd", {"memory=/machine.slice/libpod-" + kA + ".scope"}, "aaaaaaaaaaaa"}, + {"ConmonExcluded", {"memory=/machine.slice/libpod-conmon-" + kA + ".scope"}, ""}, + {"ShortIDRejected", {"memory=/docker/aaaaaaaaaaaa"}, ""}, + {"NonhexRejected", {"memory=/docker/" + std::string(64, 'z')}, ""}, + {"InvalidSeparatorRejected", {"memory=/docker_" + kA}, ""}, + {"SameIDAcrossControllers", {"memory=" + kContainer, "cpu=" + kContainer}, "aaaaaaaaaaaa"}, + {"MatchThenHost", {"memory=" + kContainer, "cpuset=/"}, "aaaaaaaaaaaa"}, + {"HostThenMatch", {"cpuset=/", "memory=" + kContainer}, "aaaaaaaaaaaa"}, + {"MatchBetweenHosts", {"cpu=/", "memory=" + kContainer, "cpuset=/"}, "aaaaaaaaaaaa"}, + }; +} + +enum class Origin { Host, + HostPIDContainer, + PIDNamespaceContainer }; +enum class ForkOrder { ParentThenChild, + ChildThenParent, + ChildOnly, + ParentOnly }; + +struct ForkCase { + std::string name; + ppm_event_code event_type; + Origin origin; + ForkOrder order; +}; + +inline void PrintTo(const ForkCase& scenario, std::ostream* out) { + *out << scenario.name; +} + +inline std::vector ForkCases() { + std::vector cases; + const std::vector> events = { + {"Fork", PPME_SYSCALL_FORK_20_X}, + {"Clone", PPME_SYSCALL_CLONE_20_X}, + {"Clone3", PPME_SYSCALL_CLONE3_X}, + }; + const std::vector> origins = { + {"Host", Origin::Host}, + {"HostPID", Origin::HostPIDContainer}, + {"PIDNamespace", Origin::PIDNamespaceContainer}, + }; + const std::vector> orders = { + {"ParentThenChild", ForkOrder::ParentThenChild}, + {"ChildThenParent", ForkOrder::ChildThenParent}, + {"ChildOnly", ForkOrder::ChildOnly}, + {"ParentOnly", ForkOrder::ParentOnly}, + }; + for (const auto& [event_name, event] : events) { + for (const auto& [origin_name, origin] : origins) { + for (const auto& [order_name, order] : orders) { + // A parent in a PID namespace cannot supply the global child TID. + // Falco deliberately waits for the child event; no valid-ID oracle here. + if (origin == Origin::PIDNamespaceContainer && order == ForkOrder::ParentOnly) { + continue; + } + cases.push_back({event_name + "_" + origin_name + "_" + order_name, + event, origin, order}); + } + } + } + return cases; +} + +} // namespace collector::replay_corpus diff --git a/collector/test/plugin-replay/README.md b/collector/test/plugin-replay/README.md new file mode 100644 index 0000000000..6bba5aa1d5 --- /dev/null +++ b/collector/test/plugin-replay/README.md @@ -0,0 +1,159 @@ +# Container plugin replay tests + +Use this suite to check container attribution, host filtering and selected network +handling without starting Collector against a live kernel or Kubernetes cluster. +It supplies synthetic process events to the real Falco parser, loads the compiled +container plugin, and checks the answers through Collector's production code. + +## Build and run + +Run inside a Linux Collector builder environment, with the repository at `/src`. +Initialize the required submodules first. The source tree must be writable because +Falco generates some headers there during the build. + +```sh +cd /src +git submodule update --init falcosecurity-libs collector/proto/third_party/stackrox +cmake -S /src -B /build \ + -DBUILD_PLUGIN_REPLAY_TESTS=ON \ + -DCMAKE_BUILD_TYPE=Debug -DDISABLE_PROFILING=ON +cmake --build /build --target ContainerPluginReplayTest -j2 +bash /src/collector/test/plugin-replay/run-corpus.sh /build /tmp/plugin-results +``` + +The runner writes `run.log` and `results.xml` and returns nonzero if any assertion +fails. It sets the plugin path automatically. To select or list cases, pass GTest +arguments after the two directory arguments: + +```sh +bash /src/collector/test/plugin-replay/run-corpus.sh /build /tmp/plugin-results \ + --gtest_filter='*MatchingCgroup*' +bash /src/collector/test/plugin-replay/run-corpus.sh /build /tmp/plugin-results \ + --gtest_list_tests +``` + +CTest also registers the target and supplies its plugin path: + +```sh +ctest --test-dir /build -R '^ContainerPluginReplayTest$' --output-on-failure +``` + +For order-dependent failures, set `REPLAY_REPEAT=100 REPLAY_SEED=3939` before the +runner command. This repeats and shuffles the same cases, not their event contents. +The log retains every iteration; the XML describes only the last iteration. +`ROX_COLLECTOR_CONTAINER_PLUGIN_PATH` can select a compatible plugin module, but +does not change the linked Falco or Collector version. + +## How a test works + +Each case starts with a fresh inspector and plugin. `SeedThread` supplies the +initial process inventory and cgroups. `Open` starts the inspector, letting Falco +invoke the plugin's capture callback. Event helpers then feed synthetic events +through Falco's TEST_INPUT engine; Falco performs parsing, process-table updates +and plugin callbacks. Tests do not call those callbacks directly or populate the +plugin's cached container-ID field. + +`ExpectAttribution` checks Collector's container ID and the plugin-backed filter. +Network-focused cases additionally exercise `NetworkSignalHandler` and +`ConnectionTracker`. Expected IDs are explicit test data, never calculated with +the plugin's extraction function. + +Process IDs simply connect events to their parent or child. For example, Falco +may learn about a child from the parent's fork event before observing the child's +event. Keeping those inputs separate lets a test check attribution when events +arrive in a different order or one is missing. + +## Extend the corpus + +The corpus has three groups: + +- Startup layouts in `Corpus.h`: Docker, CRI-O, containerd and Podman cgroups, + host/conmon exclusion, malformed IDs and cgroup ordering. +- Process-creation combinations in `Corpus.h`: fork/clone/clone3, host or container + origins, and parent/child event ordering. The parameterized test supplies events. +- Focused `TEST_F` cases in `ContainerPluginReplayTest.cpp`: exec refresh, process-ID + reuse, thread clone, vfork, late discovery, filtering and connection tracking. + +### Add a cgroup layout + +Add a named `StartupCase` to `StartupCases()` with controller-prefixed cgroup +strings and an explicit expected short ID (or `""` for host/excluded activity). +For example, a new ordering case could be: + +```cpp +{"HostThenDocker", {"cpuset=/", "memory=/docker/" + kA}, "aaaaaaaaaaaa"}, +``` + +The existing parameterized test seeds the process and checks attribution and +filtering. Use a unique descriptive name so the case is easy to select in GTest. + +### Add a lifecycle scenario + +Add a `TEST_F(ContainerPluginReplayTest, DescriptiveName)` in the replay test file: + +1. Seed only the processes known before capture, then call `Open()`. +2. Generate the smallest valid event sequence needed for the scenario, using the + existing Falco helpers. Keep timestamps increasing and parent/child IDs coherent. +3. Assert prerequisites such as the child's existence and cgroups before checking + attribution. A missing parser-created process is different from a plugin bug. +4. Check the expected ID and filtering decision; use the network-handler helper + when the requirement is that a connection actually reaches the tracker. +5. Include a nearby positive control when omitting or reordering an event, and + run the focused case followed by the full corpus. + +Extend `ForkCases()` only when an event has the same encoding and expectations as +the existing parameterized test. Use a focused case for a different lifecycle. +Do not remove its parent-only PID-namespace exclusion: that event supplies a +namespace-local child ID, insufficient to create the global child entry. Similarly, +vfork sequences must respect the child's exit before the parent's return. + +## CI and maintenance + +Main CI calls `.github/workflows/plugin-validator.yml` alongside unit tests using +the same builder-tag output. It builds the standard checkout and pinned submodules +on AMD64 and ARM64 with ASan/UBSan, runs the corpus once, and uploads logs, XML and build/revision +information even on failure. Assertions are not skipped or converted to success. + +To match CI locally, add `-DADDRESS_SANITIZER=ON` and +`'-DCMAKE_C_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer'` to the +Debug configure command in a separate build directory. The repository's +`ADDRESS_SANITIZER` option enables both sanitizers for C++; the C flags also +instrument libscap. Run with `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1` and +`UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1`. Prebuilt external libraries +are not rebuilt with instrumentation. Valgrind is not used by this workflow. + +`BUILD_PLUGIN_REPLAY_TESTS` is opt-in. Its CMake target compiles the pinned Falco +test helpers and enables TEST_INPUT without enabling the entire upstream suite. +Normal builds are unchanged when the option is off. When updating Falco, check +helper signatures and event semantics as well as whether the target still builds. + +## Boundaries to preserve + +- Synthetic input does not test live kernel capture, actual event loss, runtime + discovery, deployment or Sensor delivery. Add live integration tests for those. +- `ImportLateThread` models the result of a successful `/proc` lookup by inserting + a valid process into Falco's thread manager. TEST_INPUT has no live lookup callback; + keep those tests clearly distinguished from event-only reproductions. +- Filter construction is currently copied from `Service.cpp`. Keep it aligned + with production configuration until a shared helper replaces the duplication. +- Replays are deterministic correctness tests, not random fuzzing or CPU benchmarks. + If adding structured mutation, retain reproducible seeds and promote minimized + failures to named cases. Measure performance separately with controlled workloads. + +## Next steps + +Planned extensions, not currently supported: + +- Expand the corpus with cgroup changes after startup, plugin restart/reinitialization, + and callback read/write failures. Keep failure injection separate from valid + event-sequence tests. +- Add bounded structured fuzzing of valid event sequences. Save reproducible seeds + and promote minimized failures into named regression cases. +- Extend replay first to the post-upgrade Collector without the plugin, then to + the pre-upgrade Collector. Reuse scenario expectations, but build each revision + with its own pinned Falco dependency and event/attribution adapter; older output + is a comparison, not the correctness oracle. +- Add packaged-image integration tests for live discovery, event loss and signal + delivery. Keep controlled CPU benchmarks separate from correctness replay. + +Link these items to tracking issues as the work is scoped. diff --git a/collector/test/plugin-replay/run-corpus.sh b/collector/test/plugin-replay/run-corpus.sh new file mode 100644 index 0000000000..d4f7a5a19a --- /dev/null +++ b/collector/test/plugin-replay/run-corpus.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Run an already-built validator and retain its actual exit status and evidence. +set -euo pipefail + +build_dir="${1:?Usage: bash run-corpus.sh BUILD_DIR RESULTS_DIR [gtest arguments...]}" +results_dir="${2:?Usage: bash run-corpus.sh BUILD_DIR RESULTS_DIR [gtest arguments...]}" +shift 2 + +test -x "$build_dir/collector/test/plugin-replay/ContainerPluginReplayTest" +mkdir -p "$results_dir" +export ROX_COLLECTOR_CONTAINER_PLUGIN_PATH="${ROX_COLLECTOR_CONTAINER_PLUGIN_PATH:-$build_dir/collector/collector-container-plugin.so}" + +"$build_dir/collector/test/plugin-replay/ContainerPluginReplayTest" \ + --gtest_repeat="${REPLAY_REPEAT:-1}" \ + --gtest_shuffle --gtest_random_seed="${REPLAY_SEED:-3939}" \ + --gtest_output="xml:$results_dir/results.xml" \ + "$@" 2>&1 | tee "$results_dir/run.log"