fold/tests/integration/test_sast_security_e2e.cpp
trvon fd4a68dfff
test: add coverage for project graph, pcode encoder, and detector behaviors
Unit + integration coverage for the session's work: incremental project
graph/index, pcode encoder, unreachable/dead-branch, equiv/cross-TU,
div-by-zero/sign-conversion/int-truncation, use-after-free, quality-precision,
scan-router profile, source TU runner, detector plan, and the eval fixtures.
2026-08-16 16:46:07 -06:00

136 lines
5.4 KiB
C++

// End-to-end SAST coverage for the security detectors: run the scan runner over
// the committed source fixtures and assert proved findings on the vulnerable
// cases AND a clean negative on a guarded case (no false positive).
//
// Note: null_guarded.cpp is intentionally NOT used as a negative — its own
// comment documents that the current first-cut null detector flags it (a known
// FP a later milestone will fix), so div_guarded.cpp is the true-negative case.
#include <clang/Tooling/CompilationDatabase.h>
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
#include "fold/cli/scan_options.hpp"
#include "fold/cli/scan_runner.hpp"
#include "fold/frontend/comp_db.hpp"
#include "fold/report/finding.hpp"
namespace {
namespace fs = std::filesystem;
// Locate tests/fixtures/security — via env (set by meson) or a path probe.
fs::path securityFixturesDir() {
if (const char* env = std::getenv("FOLD_SECURITY_FIXTURES"); env != nullptr && *env != '\0')
return fs::path(env);
for (const fs::path& cand :
{fs::path("../fixtures/security"), fs::path("tests/fixtures/security"),
fs::current_path() / "../fixtures/security"})
if (fs::exists(cand / "div_const_zero.cpp"))
return cand;
return fs::path("../fixtures/security");
}
struct TempDir {
fs::path path;
TempDir()
: path(fs::temp_directory_path() /
("fold-sast-e2e-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()))) {
fs::create_directories(path);
}
~TempDir() { fs::remove_all(path); }
};
// Write a compile_commands.json (in `dir`) referencing the given absolute source
// paths, and return them for the shard runner.
std::vector<std::string> writeDb(const fs::path& dir, const std::vector<fs::path>& sources) {
nlohmann::json db = nlohmann::json::array();
std::vector<std::string> files;
for (const auto& src : sources) {
db.push_back({{"directory", dir.string()},
{"command", "clang++ -std=c++20 -c " + src.string()},
{"file", src.string()}});
files.push_back(src.string());
}
std::ofstream(dir / "compile_commands.json") << db.dump();
return files;
}
std::vector<fold::Finding> scanFixtures(const std::vector<fs::path>& sources,
const std::vector<std::string>& detectors) {
TempDir dir;
auto files = writeDb(dir.path, sources);
std::string err;
auto db = fold::loadCompilationDatabase(dir.path.string(), err);
REQUIRE(db);
fold::cli::ScanOptions opts;
opts.compile_db = dir.path.string();
opts.detectors = detectors;
return fold::cli::runDetectorsOnShard(*db, opts, files, /*include_clone_clustering=*/false)
.findings;
}
bool hasKind(const std::vector<fold::Finding>& fs, fold::FindingKind k) {
for (const auto& f : fs)
if (f.kind == k)
return true;
return false;
}
} // namespace
TEST_CASE("sast e2e: security detectors prove div-by-zero and null-deref",
"[e2e][sast][security]") {
const auto dir = securityFixturesDir();
REQUIRE(fs::exists(dir / "div_const_zero.cpp"));
const auto findings = scanFixtures({dir / "div_const_zero.cpp", dir / "null_unconditional.cpp"},
{"div-by-zero", "null-deref"});
REQUIRE(hasKind(findings, fold::FindingKind::DivByZero)); // x / 0
REQUIRE(hasKind(findings, fold::FindingKind::NullDeref)); // *(int*)nullptr
}
TEST_CASE("sast e2e: full security group preserves composite observer findings",
"[e2e][sast][security][composite]") {
const auto dir = securityFixturesDir();
REQUIRE(fs::exists(dir / "null_unconditional.cpp"));
// The security group currently installs more observers than the original
// fixed composite-run reserve. Growing that vector after observer creation
// used to invalidate their findings/stats references and silently lose the
// null-deref query.
const auto findings = scanFixtures({dir / "null_unconditional.cpp"}, {"security"});
REQUIRE(hasKind(findings, fold::FindingKind::NullDeref));
}
TEST_CASE("sast e2e: a guarded divisor is not flagged (no false positive)",
"[e2e][sast][security]") {
const auto dir = securityFixturesDir();
REQUIRE(fs::exists(dir / "div_guarded.cpp"));
const auto findings = scanFixtures({dir / "div_guarded.cpp"}, {"div-by-zero"});
REQUIRE_FALSE(hasKind(findings, fold::FindingKind::DivByZero)); // path predicate rules out y==0
}
TEST_CASE("sast e2e: sysconf page-size wrappers are not flagged as div-by-zero",
"[e2e][sast][security][sysconf]") {
const auto dir = securityFixturesDir();
REQUIRE(fs::exists(dir / "sysconf_pagesize_safe.cpp"));
const auto findings = scanFixtures({dir / "sysconf_pagesize_safe.cpp"}, {"div-by-zero"});
REQUIRE_FALSE(hasKind(findings, fold::FindingKind::DivByZero));
}
TEST_CASE("sast e2e: strtol endptr writes are not flagged as null-deref",
"[e2e][sast][security][strtol]") {
const auto dir = securityFixturesDir();
REQUIRE(fs::exists(dir / "strtol_endptr_safe.cpp"));
const auto findings = scanFixtures({dir / "strtol_endptr_safe.cpp"}, {"null-deref"});
REQUIRE_FALSE(hasKind(findings, fold::FindingKind::NullDeref));
}