fold/tests/unit/test_finding_query.cpp

244 lines
11 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <sstream>
#include <string>
#include <vector>
#include "fold/report/finding_query.hpp"
#include "fold/report/finding_report_reader.hpp"
#include "fold/report/findings_json_report.hpp"
#include "fold/report/sarif.hpp"
namespace {
fold::Finding makeFinding(std::string id, fold::FindingKind kind, fold::Confidence confidence,
std::string backend, std::string path, std::string function,
std::string message) {
fold::Finding finding;
finding.id = id;
finding.kind = kind;
finding.confidence = confidence;
finding.evidence.backend = backend;
finding.evidence.detector_name = "fixture-detector";
finding.locations.push_back({path, 12, 4, function});
finding.message = message;
return finding;
}
std::vector<fold::Finding> fixtureFindings() {
auto binary = makeFinding("finding-c", fold::FindingKind::HardcodedCredential,
fold::Confidence::Heuristic, "binary", "bin/app", "sym.auth",
"Embedded API Token");
binary.suppression_reason = "accepted test fixture";
binary.baseline_status = "accepted";
binary.evidence.binary_string = "token=secret";
fold::ExploitContext exploit_context;
exploit_context.gadgets.push_back({0x1000, "pop rdi; ret", "reg-control"});
fold::RopChainInfo chain;
chain.steps.push_back({"gadget", 0x1000, 0, "pop", 24});
exploit_context.chain = chain;
binary.evidence.exploit_context = exploit_context;
auto source =
makeFinding("finding-a", fold::FindingKind::NullDeref, fold::Confidence::Proved, "source",
"src/auth.cpp", "parse_user", "Null pointer reaches user parser");
source.baseline_status = "new";
source.evidence.proof_mode = "bounded-z3";
source.evidence.bail_reasons = {"unsupported-call"};
source.evidence.path_trace = {"entry", "ptr == nullptr", "dereference"};
fold::QualityEvidence quality;
quality.stable_anchor = "function-anchor";
quality.representative_id = "source-member";
quality.classification = "product-logic";
quality.member_ids = {"source-member"};
quality.rank_reasons = {"nested-control-flow"};
quality.metrics = {{"family_size", 1.0}};
quality.incomplete = true;
quality.uncertainty_reasons = {"comparison-cap"};
source.evidence.quality = quality;
auto rust = makeFinding("finding-b", fold::FindingKind::RustPathTraversal,
fold::Confidence::Heuristic, "rust", "tools/app/src/main.rs",
"read_config", "Untrusted path reaches filesystem API");
auto swift = makeFinding("finding-d", fold::FindingKind::SwiftUnsafeCast,
fold::Confidence::Heuristic, "swift", "Sources/App/main.swift",
"decodeConfig", "Unchecked Swift cast reaches decoder");
return {binary, source, rust, swift};
}
fold::FindingReport readNative(const std::vector<fold::Finding>& findings) {
std::ostringstream encoded;
fold::writeFindingsJson(encoded, findings);
std::istringstream input(encoded.str());
return fold::readFindingReport(input, "fixture.findings.json");
}
fold::FindingReport readSarif(const std::vector<fold::Finding>& findings) {
std::ostringstream encoded;
fold::writeSarif(encoded, findings);
std::istringstream input(encoded.str());
return fold::readFindingReport(input, "fixture.sarif");
}
std::vector<std::string> ids(const std::vector<fold::Finding>& findings) {
std::vector<std::string> output;
for (const auto& finding : findings)
output.push_back(finding.id);
return output;
}
} // namespace
TEST_CASE("finding report reader accepts native JSON and SARIF", "[finding-query][reader]") {
const auto findings = fixtureFindings();
const auto native = readNative(findings);
const auto sarif = readSarif(findings);
REQUIRE(native.format == fold::FindingReportFormat::FindingsJson);
REQUIRE(sarif.format == fold::FindingReportFormat::Sarif);
REQUIRE(native.findings.size() == 4);
REQUIRE(sarif.findings.size() == 4);
fold::FindingQueryOptions query;
query.limit = 10;
const auto native_result = fold::queryFindings(native.findings, query);
const auto sarif_result = fold::queryFindings(sarif.findings, query);
REQUIRE(ids(native_result.findings) ==
std::vector<std::string>{"finding-a", "finding-b", "finding-c", "finding-d"});
REQUIRE(ids(sarif_result.findings) == ids(native_result.findings));
REQUIRE(sarif_result.findings[0].evidence.backend == "source");
REQUIRE(sarif_result.findings[0].evidence.proof_mode == "bounded-z3");
REQUIRE(sarif_result.findings[0].evidence.bail_reasons ==
std::vector<std::string>{"unsupported-call"});
REQUIRE(sarif_result.findings[0].evidence.quality.has_value());
REQUIRE(sarif_result.findings[0].evidence.quality->stable_anchor == "function-anchor");
REQUIRE(sarif_result.findings[0].evidence.quality->classification == "product-logic");
REQUIRE(sarif_result.findings[0].evidence.quality->metrics.front().name == "family_size");
REQUIRE(sarif_result.findings[0].evidence.quality->incomplete);
REQUIRE(sarif_result.findings[1].evidence.backend == "rust");
REQUIRE(sarif_result.findings[2].evidence.backend == "binary");
REQUIRE(sarif_result.findings[2].suppression_reason == "accepted test fixture");
REQUIRE(sarif_result.findings[2].evidence.exploit_context->chain->steps.front().stack_offset ==
24);
REQUIRE(sarif_result.findings[3].evidence.backend == "swift");
}
TEST_CASE("finding report reader preserves unknown wire kinds", "[finding-query][reader]") {
std::ostringstream encoded;
fold::writeFindingsJson(encoded, {fixtureFindings().front()});
auto document = nlohmann::json::parse(encoded.str());
document["findings"][0]["kind"] = "future-frontend-hazard";
std::istringstream input(document.dump());
const auto report = fold::readFindingReport(input, "future.findings.json");
REQUIRE(report.findings.size() == 1);
REQUIRE(report.findings.front().kind == fold::FindingKind::UnknownForeign);
REQUIRE(report.findings.front().evidence.correlation_kind == "future-frontend-hazard");
}
TEST_CASE("finding queries match literal text only in selected fields",
"[finding-query][literal]") {
const auto report = readNative(fixtureFindings());
fold::FindingQueryOptions query;
query.pattern = "api token";
query.ignore_case = true;
query.fields = {fold::FindingQueryField::Message};
auto result = fold::queryFindings(report.findings, query);
REQUIRE(result.total_matches == 1);
REQUIRE(result.findings.front().id == "finding-c");
query.pattern = "auth.cpp";
REQUIRE(fold::queryFindings(report.findings, query).total_matches == 0);
query.fields = {fold::FindingQueryField::Path};
REQUIRE(fold::queryFindings(report.findings, query).findings.front().id == "finding-a");
query.pattern = "PTR == NULLPTR";
query.fields = {fold::FindingQueryField::Evidence};
REQUIRE(fold::queryFindings(report.findings, query).findings.front().id == "finding-a");
query.pattern = "pop rdi; ret";
REQUIRE(fold::queryFindings(report.findings, query).findings.front().id == "finding-c");
}
TEST_CASE("finding queries apply typed filters, globbing, and pagination",
"[finding-query][filters]") {
const auto report = readNative(fixtureFindings());
fold::FindingQueryOptions query;
query.backend = "source";
query.confidence = "proved";
query.rule = "fold.null-deref";
query.path_glob = "src/*.cpp";
query.baseline_status = "new";
auto filtered = fold::queryFindings(report.findings, query);
REQUIRE(filtered.total_matches == 1);
REQUIRE(filtered.findings.front().id == "finding-a");
auto pcode = makeFinding("pcode", fold::FindingKind::NullDeref, fold::Confidence::Proved,
"binary-pcode-ssa", "bin/app", "sym.parse", "P-Code null dereference");
query = {};
query.backend = "binary";
REQUIRE(fold::queryFindings({pcode}, query).total_matches == 1);
query = {};
query.suppression = fold::FindingSuppressionFilter::Suppressed;
REQUIRE(fold::queryFindings(report.findings, query).findings.front().id == "finding-c");
query = {};
query.actionable_only = true;
REQUIRE(fold::queryFindings(report.findings, query).total_matches == 3);
query = {};
query.offset = 1;
query.limit = 1;
const auto page = fold::queryFindings(report.findings, query);
REQUIRE(page.total_input == 4);
REQUIRE(page.total_matches == 4);
REQUIRE(page.total_actionable_matches == 3);
REQUIRE(page.findings.size() == 1);
REQUIRE(page.findings.front().id == "finding-b");
REQUIRE(page.next_offset == 2);
REQUIRE(page.has_more);
REQUIRE(page.truncated);
query.pattern = "does-not-exist";
const auto empty = fold::queryFindings(report.findings, query);
REQUIRE(empty.total_matches == 0);
REQUIRE(empty.findings.empty());
REQUIRE_FALSE(empty.truncated);
}
TEST_CASE("finding report reader rejects malformed or unsupported reports",
"[finding-query][reader]") {
std::istringstream malformed("{not-json");
REQUIRE_THROWS_AS(fold::readFindingReport(malformed, "broken.json"), fold::FindingReportError);
std::istringstream unsupported(R"({"output_version":2,"findings":[]})");
REQUIRE_THROWS_AS(fold::readFindingReport(unsupported, "future.json"),
fold::FindingReportError);
std::istringstream wrong_shape(R"({"output_version":1,"findings":{}})");
REQUIRE_THROWS_AS(fold::readFindingReport(wrong_shape, "wrong.json"), fold::FindingReportError);
std::istringstream malformed_sarif(R"({"version":"2.1.0","runs":{}})");
REQUIRE_THROWS_AS(fold::readFindingReport(malformed_sarif, "wrong.sarif"),
fold::FindingReportError);
std::istringstream trailing(R"({"output_version":1,"findings":[]} trailing)");
REQUIRE_THROWS_AS(fold::readFindingReport(trailing, "trailing.json"), fold::FindingReportError);
std::ostringstream valid_sarif;
fold::writeSarif(valid_sarif, {fixtureFindings().front()});
auto wrong_message = nlohmann::json::parse(valid_sarif.str());
wrong_message["runs"][0]["results"][0]["message"] = "wrong-shape";
std::istringstream wrong_message_input(wrong_message.dump());
REQUIRE_THROWS_AS(fold::readFindingReport(wrong_message_input, "wrong-message.sarif"),
fold::FindingReportError);
auto wrong_location = nlohmann::json::parse(valid_sarif.str());
wrong_location["runs"][0]["results"][0]["locations"][0] = nlohmann::json::object();
std::istringstream wrong_location_input(wrong_location.dump());
REQUIRE_THROWS_AS(fold::readFindingReport(wrong_location_input, "wrong-location.sarif"),
fold::FindingReportError);
}